Cifrar tu vídeo
Un script que va de un archivo de vídeo a contenido protegido y listo para subir. En Node o en Python, el que prefieras.
Qué hace el script #
- Pide a Airi una clave por calidad — 1080p, 720p y audio.
- Transcodifica tu vídeo a esas calidades con ffmpeg.
- Cifra y empaqueta en DASH y HLS con shaka-packager, en
cbcs. - Te dice qué subir y cómo apuntar el reproductor.
Tres claves y no una, a propósito
Ejecutarlo dos veces es seguro y gratis: pedir una clave que ya existe devuelve la misma y no se cobra. Si falla la transcodificación a mitad, vuelves a lanzarlo sin duplicar nada.
Cifrado desde el primer fotograma
--clear_lead 0 al empaquetador. Por defecto, shaka-packager deja los primeros segundos sin cifrar para que la reproducción arranque antes de que llegue la licencia — pero eso significa que esos segundos los puede ver cualquiera, con licencia o sin ella. Si empaquetas por tu cuenta, no lo olvides.Lo que necesitas instalado #
Dos herramientas. ffmpeg construye la escalera de calidades; shaka-packager cifra y escribe los manifiestos.
brew install ffmpeg shaka-packager
sudo apt install ffmpeg curl -L -o /usr/local/bin/packager \ https://github.com/shaka-project/shaka-packager/releases/latest/download/packager-linux-x64 chmod +x /usr/local/bin/packager
scoop install ffmpeg # packager: descarga packager-win-x64.exe de la pagina de releases, # renombralo a packager.exe y ponlo en el PATH
ffmpeg solo no basta
cenc y sin las cabeceras de protección que cada sistema DRM necesita. cenc deja fuera a todos los dispositivos Apple, y sin esas cabeceras el reproductor no sabe a qué servidor de licencias preguntar. Por eso hacen falta las dos herramientas.Ejecutarlo #
El token sale de Desarrolladores en tu panel. Se muestra una sola vez.
node protect.mjs \ --input pelicula.mp4 \ --content-id pelicula-1234 \ --token kms_live_...
python protect.py \ --input pelicula.mp4 \ --content-id pelicula-1234 \ --token kms_live_...
Usa identificadores desechables mientras pruebas
contentId es permanente: una vez que existe una clave para él, el proveedor no vuelve a emitir otra nunca. Prueba con algo como test-2026-07-31-01, no con el nombre real de tu catálogo.Lo que produce
protected/ ├── HD.mp4 1080p, cifrado ├── SD.mp4 720p, cifrado ├── AUDIO.mp4 cifrado ├── manifest.mpd DASH — Widevine, PlayReady └── master.m3u8 HLS — FairPlay, y los demas en Apple
Una clave por calidad #
Por defecto todo el título va con una sola clave. Con --per-quality cada calidad recibe la suya, que es lo que te permite exigir DRM por hardware al 1080p mientras el 720p sigue reproduciéndose en un portátil — la regla con la que los grandes servicios reservan sus calidades altas.
node protect.mjs \ --input pelicula.mp4 \ --content-id pelicula-1234 \ --token kms_live_... \ --per-quality
Después defines la regla de cada calidad en Reglas de reproducción, eligiendo la pista en el selector. El nivel que pidas para 1080p no afecta al 720p ni al audio.
Lo que cambia por dentro
Cada calidad se convierte en una clave independiente, y el reproductor pide una licencia por clave en vez de una sola. Eso tiene dos consecuencias que conviene saber antes de ponerlo en producción: se factura una licencia por calidad en cada reproducción, y un espectador al que le denieguen la clave de 1080p debe seguir viendo el 720p en lugar de quedarse sin vídeo.
Tu reproductor tiene que sobrevivir a una licencia denegada
drm.failureCallback, marcando error.handled = true; sin marcarlo, el callback se ejecuta y la reproducción muere igual.Widevine y PlayReady no deniegan igual
Esta es la parte que sorprende. Widevine emite la licencia y deja que el dispositivo marque la clave como inservible, así que el reproductor baja de calidad y sigue. PlayReady, en cambio, rechaza en el servidor: si le exiges un nivel que el cliente no alcanza, responde un error en lugar de una clave, y para el reproductor eso no es «esta calidad no» sino «esta reproducción no».
La misma regla se escribe idéntica en el panel y se comporta al revés en cada sistema. Si tu audiencia incluye Windows o Xbox, exige hardware en Widevine y deja PlayReady en SL2000 — o comprueba primero, en un dispositivo que NO cumpla la regla, que tu reproductor sobrevive a la licencia denegada.
El script en Node #
Sin dependencias: solo Node 18 o superior. Guárdalo como protect.mjs — o descárgalo.
#!/usr/bin/env node /** * Protect a video with Airi: keys → transcode → encrypt → ready to upload. * * node protect.mjs --input movie.mp4 --content-id movie-1234 --token kms_live_... * * Requires ffmpeg and shaka-packager on PATH. * * Why two tools: ffmpeg transcodes — it makes the quality ladder — and * shaka-packager encrypts and writes the manifests. ffmpeg can encrypt, but * only in `cenc`, and without the per-system protection headers that Widevine, * PlayReady and FairPlay each need. Packaging in `cenc` would also lock out * every Apple device, which only speaks `cbcs`. */ import { spawn } from 'node:child_process'; import { mkdir, rm, readFile, writeFile, unlink } from 'node:fs/promises'; import { join } from 'node:path'; const API = process.env.AIRI_API || 'https://kms.airi.live'; /** * The quality ladder. Every rung is encoded; whether each gets its own key * depends on --per-quality. */ const LADDER = [ { label: 'HD', height: 1080, bitrate: '5000k', kind: 'video' }, { label: 'SD', height: 720, bitrate: '2500k', kind: 'video' }, { label: 'AUDIO', kind: 'audio', bitrate: '128k' } ]; const DRM_SYSTEMS = ['widevine', 'playready', 'fairplay']; // ---------------------------------------------------------------- arguments function arg(name, fallback = undefined) { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : fallback; } const input = arg('input'); const contentId = arg('content-id'); const token = arg('token', process.env.AIRI_TOKEN); const outDir = arg('out', './protected'); const perQuality = process.argv.includes('--per-quality'); /** * cbcs reaches Apple; cenc reaches PlayReady in a browser. Many Edge clients * report PlayReady as hardware-backed and still cannot read cbcs — the licence * is issued and the CDM cannot decrypt. Needing both means packaging twice, * with the same keys. */ const scheme = arg('scheme', 'cbcs'); /** * Argument validation, deliberately not at module scope: the tests import * this file for its manifest merging, and a bare process.exit() at import * time would kill the test run instead of reporting anything. */ function validateArgs() { if (scheme !== 'cbcs' && scheme !== 'cenc') { console.error(`--scheme must be cbcs or cenc, got "${scheme}"`); process.exit(1); } if (!input || !contentId || !token) { console.error(` Usage: node protect.mjs --input <file> --content-id <id> --token <kms_live_...> --input Source video --content-id Your identifier for this title. PERMANENT: once a key exists for it, the provider will never issue another one. --token API credential, from the Developers page (or AIRI_TOKEN) --out Output directory (default ./protected) --per-quality One key per quality instead of one for the whole title. Lets you demand hardware DRM for 1080p while 720p stays playable on a laptop. Costs one licence request per quality. --scheme cbcs (default) or cenc. cbcs reaches Apple; cenc reaches PlayReady in a browser. Need both? Run it twice, same content-id, different --out — the keys are reused. `); process.exit(1); } if (!/^[A-Za-z0-9._-]+$/.test(contentId)) { console.error('The contentId may only contain letters, digits, dot, dash and underscore.'); process.exit(1); } } // ---------------------------------------------------------------- helpers /** Run a command, streaming its output so a long transcode shows progress. */ function run(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: 'inherit', shell: false }); child.on('error', err => reject( err.code === 'ENOENT' ? new Error(`${command} is not installed, or not on PATH.`) : err )); child.on('close', code => code === 0 ? resolve() : reject(new Error(`${command} exited with code ${code}`))); }); } /** * The API returns `key` in base64, because that is how the CPIX standard * defines it. Packagers want hexadecimal. Getting this wrong encrypts the video * with a key that is not yours, and nobody can ever play it — so it is worth * the two lines. */ const base64ToHex = (b64) => Buffer.from(b64, 'base64').toString('hex'); /** The keyId arrives as a dashed UUID; packagers want the bare hex. */ const uuidToHex = (uuid) => uuid.replace(/-/g, '').toLowerCase(); async function requestKey(track) { const res = await fetch(`${API}/v1/keys`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ contentId, drmSystems: DRM_SYSTEMS, ...(track ? { track } : {}) }) }); const body = await res.json(); if (!res.ok || body.success === false) { const error = body.error ?? {}; if (error.code === 'INSUFFICIENT_FUNDS') { throw new Error('Not enough balance. Top up at https://airi.live/app/billing'); } throw new Error(`${error.code ?? res.status}: ${error.message ?? 'request failed'}`); } // `created: false` means this key already existed — repeating a request is // safe and free, which is what makes the pipeline retryable. console.log(` ${(track || 'todo').padEnd(6)} ${body.keyId} ${body.created ? '(new)' : '(existing)'}`); return body; } /** * The provider's own protection headers, concatenated as hex for `--pssh`. * * Easy to skip and it fails late. shaka-packager will happily generate its own * PSSH from the key id, and the result looks correct, packages cleanly and * plays nowhere: the header the licence server expects is the one it issued, * carrying the identifiers it recognises. A generated one is answered with * "ContentID parameter is required" or "Keys not found", neither of which * points at the packaging command that caused it. */ function psshHex(drmSystemsData) { return (drmSystemsData ?? []) .filter(d => d.pssh) .map(d => Buffer.from(d.pssh, 'base64').toString('hex')) .join(''); } // ------------------------------------------------------- per-quality merging /** * Why per-quality keys need one packager run per quality. * * Each quality has its own key and therefore its own protection header. But * `--pssh` is a single global argument, and the packager keeps only the first * header per DRM system: pass all three and every quality ends up advertising * the first one. The player then asks for one licence, gets one key, and the * other two qualities have nothing to decrypt with. * * That failure is quiet — the manifest looks right and the video simply never * starts — which is what makes it worth the extra runs. Each quality is * packaged on its own so it carries its own header, and the outputs are then * spliced into one manifest, which is what these two functions do. */ /** One DASH manifest out of several single-track ones. */ async function mergeMpd(parts, output) { const docs = await Promise.all(parts.map(p => readFile(p, 'utf8'))); const sets = []; for (const doc of docs) { for (const m of doc.matchAll(/[ \t]*<AdaptationSet[\s\S]*?<\/AdaptationSet>/g)) sets.push(m[0]); } // The longest track decides the presentation duration: a shorter value // would cut playback off at the end of whichever track packaged first. const longest = docs .map(d => (d.match(/mediaPresentationDuration="([^"]+)"/) || [])[1]) .filter(Boolean) .sort((a, b) => durationSeconds(b) - durationSeconds(a))[0]; const head = docs[0].slice(0, docs[0].indexOf('<AdaptationSet')); const tail = docs[0].slice(docs[0].lastIndexOf('</AdaptationSet>') + '</AdaptationSet>'.length); // ids have to be unique across the merged document; each part numbered its // own from zero. const body = sets.map((s, i) => s.replace(/<AdaptationSet id="\d+"/, `<AdaptationSet id="${i}"`)).join('\n'); await writeFile(output, head.replace(/mediaPresentationDuration="[^"]+"/, `mediaPresentationDuration="${longest}"`) + body.trimStart() + tail); } function durationSeconds(iso) { const m = /PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/.exec(iso || ''); return m ? (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0) : 0; } /** * One HLS master out of several single-track ones. * * Audio becomes a rendition rather than a variant of its own, and every video * variant is pointed at it — otherwise a player picks the audio-only variant * the audio run emitted and plays a black screen with sound. */ async function mergeHls(parts, output) { const AUDIO_GROUP = 'audio'; const renditions = []; const variants = []; for (const { label, path, kind } of parts) { const lines = (await readFile(path, 'utf8')).split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (kind === 'audio' && line.startsWith('#EXT-X-MEDIA:')) { renditions.push(line.replace(/GROUP-ID="[^"]*"/, `GROUP-ID="${AUDIO_GROUP}"`)); } // The audio run also emits a variant for audio-only playback. Kept, // it competes with the video variants; dropped, audio is available // only as a rendition, which is what a video title wants. if (kind === 'video' && line.startsWith('#EXT-X-STREAM-INF:')) { const uri = lines[++i]; variants.push(`${line},AUDIO="${AUDIO_GROUP}"`, uri); } } void label; } await writeFile(output, [ '#EXTM3U', '## Merged by protect.mjs — one key per quality, one licence per quality', '', '#EXT-X-INDEPENDENT-SEGMENTS', '', ...renditions, '', ...variants, '' ].join('\n')); } // ---------------------------------------------------------------- pipeline async function main() { validateArgs(); console.log(`\nProtecting "${contentId}" in ${scheme} with ${DRM_SYSTEMS.join(', ')}\n`); // ---- 1. keys console.log(`1/4 Requesting keys (${perQuality ? 'one per quality' : 'one for the whole title'})`); // Default: one key for everything. Each key carries its own protection // header, and shaka-packager applies `--pssh` to every stream at once — // so per-quality keys mean the manifest ends up advertising one content // when the segments were encrypted for three. See the note printed at the // end of a --per-quality run. const keys = {}; if (perQuality) { for (const rung of LADDER) keys[rung.label] = await requestKey(rung.label); } else { const single = await requestKey(undefined); for (const rung of LADDER) keys[rung.label] = single; } const primary = keys[LADDER[0].label]; // FairPlay signals through the HLS manifest rather than a pssh box, so the // packager needs the key URI our API returns instead. const fairplay = primary.drmSystemsData?.find(d => d.systemName === 'FairPlay'); const hlsKeyUri = fairplay?.uriExtXKey ? `skd://${fairplay.uriExtXKey}` : null; // ---- 2. transcode console.log('\n2/4 Transcoding with ffmpeg'); const work = join(outDir, '.work'); await rm(outDir, { recursive: true, force: true }); await mkdir(work, { recursive: true }); for (const rung of LADDER) { const target = join(work, `${rung.label}.mp4`); console.log(` ${rung.label}`); const args = rung.kind === 'video' ? [ '-y', '-i', input, '-an', '-vf', `scale=-2:${rung.height}`, '-c:v', 'libx264', '-profile:v', 'main', '-preset', 'medium', '-b:v', rung.bitrate, // A fixed keyframe interval is what lets a player switch // quality mid-playback; without it the ladder is decorative. '-g', '48', '-keyint_min', '48', '-sc_threshold', '0', target ] : [ '-y', '-i', input, '-vn', '-c:a', 'aac', '-b:a', rung.bitrate, '-ac', '2', target ]; await run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-stats', ...args]); } // ---- 3. encrypt and package console.log(`\n3/4 Encrypting with shaka-packager (${scheme})`); /** * Shared by both paths. Encrypting from the first frame is not a detail: * the default leaves the opening seconds in the clear so playback can start * before the licence arrives, which also means those seconds are watchable * by anyone, licence or not. */ const common = ['--enable_raw_key_encryption', '--protection_scheme', scheme, '--clear_lead', '0']; /** * FairPlay is signalled in the HLS playlist rather than by a pssh box, and * `--hls_key_uri` alone does not emit it: the packager writes the * `com.apple.streamingkeydelivery` line only when FairPlay is named as a * protection system. Naming it does not disturb the provider's Widevine and * PlayReady headers — verified against the packaged output. * * Only for cbcs, because FairPlay cannot read cenc at all. */ const fairplaySignalling = scheme === 'cbcs' ? ['--protection_systems', 'FairPlay'] : []; if (!perQuality) { const streams = LADDER.map(rung => `in=${join(work, `${rung.label}.mp4`)},stream=${rung.kind},` + `output=${join(outDir, `${rung.label}.mp4`)},drm_label=${rung.label}` ); const keySpec = LADDER.map(rung => { const k = keys[rung.label]; return `label=${rung.label}:key_id=${uuidToHex(k.keyId)}:key=${base64ToHex(k.key)}`; }).join(','); await run('packager', [ ...streams, '--keys', keySpec, // The provider's protection headers, not generated ones — they // carry the content identifier the licence server needs. '--pssh', psshHex(primary.drmSystemsData), ...common, ...fairplaySignalling, ...(hlsKeyUri ? ['--hls_key_uri', hlsKeyUri] : []), '--mpd_output', join(outDir, 'manifest.mpd'), '--hls_master_playlist_output', join(outDir, 'master.m3u8') ]); } else { // One run per quality, so each carries its own protection header. See // the comment above mergeMpd(). const parts = []; for (const rung of LADDER) { const k = keys[rung.label]; const fp = k.drmSystemsData?.find(d => d.systemName === 'FairPlay'); console.log(` ${rung.label}`); await run('packager', [ `in=${join(work, `${rung.label}.mp4`)},stream=${rung.kind},` + `output=${join(outDir, `${rung.label}.mp4`)},` + `drm_label=${rung.label},playlist_name=${rung.label}.m3u8`, '--keys', `label=${rung.label}:key_id=${uuidToHex(k.keyId)}:key=${base64ToHex(k.key)}`, '--pssh', psshHex(k.drmSystemsData), ...common, // Per quality too: the skd URI names the content, and each // quality is its own content at the provider. ...fairplaySignalling, ...(fp?.uriExtXKey ? ['--hls_key_uri', `skd://${fp.uriExtXKey}`] : []), '--mpd_output', join(outDir, `${rung.label}.mpd`), '--hls_master_playlist_output', join(outDir, `${rung.label}-master.m3u8`) ]); parts.push({ label: rung.label, kind: rung.kind, mpd: join(outDir, `${rung.label}.mpd`), master: join(outDir, `${rung.label}-master.m3u8`) }); } console.log(' merging manifests'); await mergeMpd(parts.map(p => p.mpd), join(outDir, 'manifest.mpd')); await mergeHls(parts.map(p => ({ label: p.label, kind: p.kind, path: p.master })), join(outDir, 'master.m3u8')); // The per-quality manifests were scaffolding. Left behind, they are // three more things that look loadable and are not. for (const p of parts) { await unlink(p.mpd).catch(() => {}); await unlink(p.master).catch(() => {}); } } await rm(work, { recursive: true, force: true }); // ---- 4. what to do next console.log(`\n4/4 Done — everything is in ${outDir}\n`); console.log('Upload it somewhere public (the media is encrypted; the keys are not in it):\n'); console.log(` aws s3 sync ${outDir} s3://YOUR-BUCKET/${contentId}/ --acl public-read\n`); console.log('Then point your player at:\n'); console.log(` DASH https://YOUR-BUCKET.s3.amazonaws.com/${contentId}/manifest.mpd`); console.log(` HLS https://YOUR-BUCKET.s3.amazonaws.com/${contentId}/master.m3u8`); /** * Printed from the response rather than written here. * * The endpoint depends on which key store issued the key, and the manifest * gives no hint which that was — hardcoding one is how a title ends up * asking the wrong server and getting "Keys not found". */ const urls = primary.licenseUrls ?? {}; console.log(` Licenses Widevine ${urls.widevine} PlayReady ${urls.playready} FairPlay ${urls.fairplay} Player setup: https://airi.live/docs/reproductores `); if (perQuality) { console.log(` NOTE — one key per quality. Each quality gets its own key inside this one title, which is what lets you demand hardware DRM for 1080p while 720p and the audio stay playable on a laptop. Two consequences worth knowing before you ship it: · A viewer whose device is refused the HD key still plays SD. Check that your player falls back rather than stopping. · One licence request per quality, so three charges per playback, not one. `); } } // Imported by the tests, which exercise the manifest merging without running // ffmpeg or spending a key. Running the file directly still just works. export { mergeMpd, mergeHls, durationSeconds }; if (import.meta.filename === process.argv[1]) { main().catch(err => { console.error(`\n${err.message}\n`); process.exit(1); }); }
El script en Python #
Solo biblioteca estándar, sin pip install. Guárdalo como protect.py — o descárgalo.
#!/usr/bin/env python3 """ Protect a video with Airi: keys -> transcode -> encrypt -> ready to upload. python protect.py --input movie.mp4 --content-id movie-1234 --token kms_live_... Requires ffmpeg and shaka-packager on PATH. Standard library only — no pip. Why two tools: ffmpeg transcodes (it builds the quality ladder) and shaka-packager encrypts and writes the manifests. ffmpeg can encrypt, but only in `cenc`, and without the per-system protection headers Widevine, PlayReady and FairPlay each need — headers the licence server relies on. Scheme: cbcs reaches Apple, cenc reaches PlayReady in a browser. Many Edge clients report PlayReady as hardware-backed and still cannot read cbcs. Needing both means running this twice with the same --content-id; the keys are reused. """ import argparse import base64 import json import os import re import shutil import subprocess import sys import urllib.error import urllib.request API = os.environ.get("AIRI_API", "https://kms.airi.live") # The quality ladder. Every rung is encoded; whether each gets its own key # depends on --per-quality. LADDER = [ {"label": "HD", "height": 1080, "bitrate": "5000k", "kind": "video"}, {"label": "SD", "height": 720, "bitrate": "2500k", "kind": "video"}, {"label": "AUDIO", "bitrate": "128k", "kind": "audio"}, ] DRM_SYSTEMS = ["widevine", "playready", "fairplay"] class Failed(Exception): """An error worth showing the user as-is, without a traceback.""" def run(command, args): """Run a command, streaming output so a long transcode shows progress.""" try: result = subprocess.run([command, *args], check=False) except FileNotFoundError: raise Failed(f"{command} is not installed, or not on PATH.") if result.returncode != 0: raise Failed(f"{command} exited with code {result.returncode}") def base64_to_hex(value: str) -> str: """ The API returns `key` in base64, because that is how CPIX defines it. Packagers want hexadecimal. Getting this wrong encrypts the video with a key that is not yours, and nobody will ever be able to play it. """ return base64.b64decode(value).hex() def uuid_to_hex(value: str) -> str: """The keyId arrives as a dashed UUID; packagers want the bare hex.""" return value.replace("-", "").lower() def pssh_hex(drm_systems_data: list) -> str: """ The provider's own protection headers, concatenated as hex for --pssh. Easy to skip and it fails late. shaka-packager will happily generate its own PSSH from the key id, and the result looks correct, packages cleanly and plays nowhere: the header the licence server expects is the one it issued, carrying the identifiers it recognises. A generated one is answered with "ContentID parameter is required" or "Keys not found", neither of which points at the packaging command that caused it. """ return "".join( base64.b64decode(d["pssh"]).hex() for d in (drm_systems_data or []) if d.get("pssh") ) def request_key(content_id: str, token: str, track: str | None) -> dict: body = {"contentId": content_id, "drmSystems": DRM_SYSTEMS} if track: body["track"] = track payload = json.dumps(body).encode() request = urllib.request.Request( f"{API}/v1/keys", data=payload, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", # urllib's default agent is "Python-urllib/3.x", which edge # protection in front of an API is entitled to treat as a bot. "User-Agent": "airi-protect/1.0", }, method="POST", ) try: with urllib.request.urlopen(request) as response: raw = response.read() except urllib.error.HTTPError as err: # An error body is not guaranteed to be JSON, or to exist at all — a # gateway can answer before the API does. Assuming otherwise turns a # readable failure into a stack trace about parsing. raw = err.read() or b"" try: error = json.loads(raw).get("error", {}) except (ValueError, AttributeError): snippet = raw.decode("utf-8", "replace").strip()[:120] raise Failed(f"HTTP {err.code} from {API}: {snippet or err.reason}") if error.get("code") == "INSUFFICIENT_FUNDS": raise Failed("Not enough balance. Top up at https://airi.live/app/billing") raise Failed(f"{error.get('code', err.code)}: {error.get('message', 'request failed')}") except urllib.error.URLError as err: raise Failed(f"Could not reach {API}: {err.reason}") try: body = json.loads(raw) except ValueError: raise Failed(f"{API} returned something that is not JSON.") # `created: false` means the key already existed — repeating a request is # safe and free, which is what makes this pipeline retryable. state = "(new)" if body.get("created") else "(existing)" print(f" {(track or 'todo'):<6} {body['keyId']} {state}") return body # --------------------------------------------------- per-quality merging # # Why per-quality keys need one packager run per quality. # # Each quality has its own key and therefore its own protection header. # # --pssh is global, and the packager keeps only the first header per DRM system: # pass all three and every quality ends up advertising the first one. The player # then asks for one licence, gets one key, and the other two qualities have # nothing to decrypt with. That failure is quiet — the manifest looks right and # the video simply never starts. # # Running the packager once per quality gives each its own header. The outputs # are then spliced into one manifest, which is what these two functions do. def duration_seconds(iso: str | None) -> float: m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?", iso or "") if not m: return 0.0 h, mi, s = m.groups() return int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0) def merge_mpd(paths: list, output: str) -> None: """One DASH manifest out of several single-track ones.""" docs = [open(p, encoding="utf-8").read() for p in paths] sets = [] for doc in docs: sets += re.findall(r"[ \t]*<AdaptationSet.*?</AdaptationSet>", doc, re.S) # The longest track decides the presentation duration: a shorter value would # cut playback off at the end of whichever track packaged first. durations = [m.group(1) for m in (re.search(r'mediaPresentationDuration="([^"]+)"', d) for d in docs) if m] longest = max(durations, key=duration_seconds) if durations else None first = docs[0] head = first[:first.index("<AdaptationSet")] tail = first[first.rindex("</AdaptationSet>") + len("</AdaptationSet>"):] # ids have to be unique across the merged document; each part numbered its # own from zero. body = "\n".join( re.sub(r'<AdaptationSet id="\d+"', f'<AdaptationSet id="{i}"', s, count=1) for i, s in enumerate(sets) ) if longest: head = re.sub(r'mediaPresentationDuration="[^"]+"', f'mediaPresentationDuration="{longest}"', head) with open(output, "w", encoding="utf-8") as f: f.write(head + body.lstrip() + tail) def merge_hls(parts: list, output: str) -> None: """ One HLS master out of several single-track ones. Audio becomes a rendition rather than a variant of its own, and every video variant is pointed at it — otherwise a player picks the audio-only variant the audio run emitted and plays a black screen with sound. """ group = "audio" renditions, variants = [], [] for part in parts: lines = open(part["master"], encoding="utf-8").read().splitlines() i = 0 while i < len(lines): line = lines[i] if part["kind"] == "audio" and line.startswith("#EXT-X-MEDIA:"): renditions.append(re.sub(r'GROUP-ID="[^"]*"', f'GROUP-ID="{group}"', line)) # The audio run also emits a variant for audio-only playback. Kept, # it competes with the video variants; dropped, audio is available # only as a rendition, which is what a video title wants. if part["kind"] == "video" and line.startswith("#EXT-X-STREAM-INF:"): i += 1 variants += [f'{line},AUDIO="{group}"', lines[i]] i += 1 with open(output, "w", encoding="utf-8") as f: f.write("\n".join([ "#EXTM3U", "## Merged by protect.py — one key per quality, one licence per quality", "", "#EXT-X-INDEPENDENT-SEGMENTS", "", *renditions, "", *variants, "" ])) def main() -> None: parser = argparse.ArgumentParser( description="Encrypt a video with Airi and package it for DASH and HLS." ) parser.add_argument("--input", required=True, help="Source video") parser.add_argument( "--content-id", required=True, help="Your identifier for this title. PERMANENT: once a key exists for " "it, the provider will never issue another one.", ) parser.add_argument( "--token", default=os.environ.get("AIRI_TOKEN"), help="API credential from the Developers page (or AIRI_TOKEN)", ) parser.add_argument("--out", default="./protected", help="Output directory") parser.add_argument( "--scheme", default="cbcs", choices=["cbcs", "cenc"], help="cbcs (default) reaches Apple; cenc reaches PlayReady in a browser. " "Need both? Run twice with the same --content-id and a different " "--out; the keys are reused.", ) parser.add_argument( "--per-quality", action="store_true", help="One key per quality instead of one for the whole title. Read the " "note it prints before using it in production.", ) args = parser.parse_args() if not args.token: raise Failed("Missing --token (or set AIRI_TOKEN).") if not re.fullmatch(r"[A-Za-z0-9._-]+", args.content_id): raise Failed("The contentId may only contain letters, digits, dot, dash and underscore.") print(f'\nProtecting "{args.content_id}" in {args.scheme} ' f'with {", ".join(DRM_SYSTEMS)}\n') # ---- 1. keys mode = "one per quality" if args.per_quality else "one for the whole title" print(f"1/4 Requesting keys ({mode})") # Default: one key for everything. Each key carries its own protection # header, and shaka-packager applies --pssh to every stream at once — so # per-quality keys mean the manifest ends up advertising one content while # the qualities were encrypted under different keys. if args.per_quality: keys = {r["label"]: request_key(args.content_id, args.token, r["label"]) for r in LADDER} else: single = request_key(args.content_id, args.token, None) keys = {r["label"]: single for r in LADDER} primary = keys[LADDER[0]["label"]] # FairPlay signals through the HLS manifest rather than a pssh box, so the # packager needs the key URI our API returns instead. fairplay = next( (d for d in primary.get("drmSystemsData", []) if d.get("systemName") == "FairPlay"), None, ) hls_key_uri = f"skd://{fairplay['uriExtXKey']}" if fairplay and fairplay.get("uriExtXKey") else None # ---- 2. transcode print("\n2/4 Transcoding with ffmpeg") work = os.path.join(args.out, ".work") shutil.rmtree(args.out, ignore_errors=True) os.makedirs(work, exist_ok=True) for rung in LADDER: target = os.path.join(work, f"{rung['label']}.mp4") print(f" {rung['label']}") if rung["kind"] == "video": options = [ "-an", "-vf", f"scale=-2:{rung['height']}", "-c:v", "libx264", "-profile:v", "main", "-preset", "medium", "-b:v", rung["bitrate"], # A fixed keyframe interval is what lets a player switch quality # mid-playback; without it the ladder is decorative. "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", ] else: options = ["-vn", "-c:a", "aac", "-b:a", rung["bitrate"], "-ac", "2"] run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-stats", "-y", "-i", args.input, *options, target]) # ---- 3. encrypt and package print(f"\n3/4 Encrypting with shaka-packager ({args.scheme})") common = [ "--enable_raw_key_encryption", # cbcs reaches Apple; cenc reaches PlayReady in a browser. Many Edge # clients report PlayReady as hardware-backed and still cannot read # cbcs — the licence is issued and the CDM cannot decrypt. "--protection_scheme", args.scheme, # Encrypt from the first frame. The default leaves the opening seconds # in the clear so playback can start before the licence arrives — which # also means those seconds are watchable by anyone, licence or not. "--clear_lead", "0", ] # FairPlay is signalled in the HLS playlist rather than by a pssh box, and # --hls_key_uri alone does not emit it: the packager writes the # com.apple.streamingkeydelivery line only when FairPlay is named as a # protection system. Naming it leaves the provider's Widevine and PlayReady # headers untouched. Only for cbcs — FairPlay cannot read cenc at all. fairplay_signalling = ["--protection_systems", "FairPlay"] if args.scheme == "cbcs" else [] if not args.per_quality: streams = [ f"in={os.path.join(work, rung['label'])}.mp4,stream={rung['kind']}," f"output={os.path.join(args.out, rung['label'])}.mp4,drm_label={rung['label']}" for rung in LADDER ] key_spec = ",".join( f"label={rung['label']}" f":key_id={uuid_to_hex(keys[rung['label']]['keyId'])}" f":key={base64_to_hex(keys[rung['label']]['key'])}" for rung in LADDER ) run("packager", [ *streams, "--keys", key_spec, # The provider's protection headers, not generated ones — they # carry the content identifier the licence server needs. "--pssh", pssh_hex(primary.get("drmSystemsData")), *common, *fairplay_signalling, *(["--hls_key_uri", hls_key_uri] if hls_key_uri else []), "--mpd_output", os.path.join(args.out, "manifest.mpd"), "--hls_master_playlist_output", os.path.join(args.out, "master.m3u8"), ]) else: # One run per quality, so each carries its own protection header. See # merge_mpd() for why a single run cannot do it. parts = [] for rung in LADDER: k = keys[rung["label"]] fp = next((d for d in k.get("drmSystemsData", []) if d.get("systemName") == "FairPlay"), None) print(f" {rung['label']}") run("packager", [ f"in={os.path.join(work, rung['label'])}.mp4,stream={rung['kind']}," f"output={os.path.join(args.out, rung['label'])}.mp4," f"drm_label={rung['label']},playlist_name={rung['label']}.m3u8", "--keys", f"label={rung['label']}:key_id={uuid_to_hex(k['keyId'])}:key={base64_to_hex(k['key'])}", "--pssh", pssh_hex(k.get("drmSystemsData")), *common, *fairplay_signalling, # Per quality too: the skd URI names the content, and each # quality is its own content at the provider. *(["--hls_key_uri", f"skd://{fp['uriExtXKey']}"] if fp and fp.get("uriExtXKey") else []), "--mpd_output", os.path.join(args.out, f"{rung['label']}.mpd"), "--hls_master_playlist_output", os.path.join(args.out, f"{rung['label']}-master.m3u8"), ]) parts.append({ "label": rung["label"], "kind": rung["kind"], "mpd": os.path.join(args.out, f"{rung['label']}.mpd"), "master": os.path.join(args.out, f"{rung['label']}-master.m3u8"), }) print(" merging manifests") merge_mpd([p["mpd"] for p in parts], os.path.join(args.out, "manifest.mpd")) merge_hls(parts, os.path.join(args.out, "master.m3u8")) # The per-quality manifests were scaffolding. Left behind, they are # three more things that look loadable and are not. for p in parts: for f in (p["mpd"], p["master"]): try: os.remove(f) except OSError: pass shutil.rmtree(work, ignore_errors=True) # ---- 4. what to do next print(f"\n4/4 Done — everything is in {args.out}\n") print("Upload it somewhere public (the media is encrypted; the keys are not in it):\n") print(f" aws s3 sync {args.out} s3://YOUR-BUCKET/{args.content_id}/ --acl public-read\n") print("Then point your player at:\n") print(f" DASH https://YOUR-BUCKET.s3.amazonaws.com/{args.content_id}/manifest.mpd") print(f" HLS https://YOUR-BUCKET.s3.amazonaws.com/{args.content_id}/master.m3u8") # Printed from the response rather than written here: the endpoint depends # on which key store issued the key, and the manifest gives no hint which # that was. Hardcoding one is how a title ends up asking the wrong server. urls = primary.get("licenseUrls", {}) print(f""" Licenses Widevine {urls.get('widevine')} PlayReady {urls.get('playready')} FairPlay {urls.get('fairplay')} Player setup: https://airi.live/docs/reproductores """) if args.per_quality: print(""" NOTE — one key per quality. Each quality gets its own key inside this one title, which is what lets you demand hardware DRM for 1080p while 720p and the audio stay playable on a laptop. Two consequences worth knowing before you ship it: · A viewer whose device is refused the HD key still plays SD. Check that your player falls back rather than stopping. · One licence request per quality, so three charges per playback, not one. """) if __name__ == "__main__": try: main() except Failed as failure: print(f"\n{failure}\n", file=sys.stderr) sys.exit(1)
Subirlo #
Lo que sale se puede servir públicamente sin miedo: el vídeo está cifrado y las claves no están dentro. Un reproductor tiene que pedir la licencia, y ahí es donde se aplican tus reglas.
Aun así, servirlo desde nuestro almacenamiento te ahorra montar un bucket, sus permisos, su CORS y su CDN — y añade enlaces que caducan por espectador. Si ya tienes tu propia infraestructura, más abajo está el camino con S3.
Con el almacenamiento de Airi
Sube cada fichero del paquete y pide un enlace de directorio para el manifiesto. Ese detalle no es opcional: un manifiesto referencia sus segmentos por ruta relativa, así que la firma tiene que cubrir la carpeta entera o los segmentos se quedarían fuera.
# Un fichero a la vez: declarar, subir los bytes, cerrar. for f in ./protected/*; do RES=$(curl -s -X POST https://kms.airi.live/v1/storage/files \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d "{\"name\":\"$(basename $f)\",\"sizeBytes\":$(stat -c%s $f)}") ID=$(echo $RES | jq -r .file.id) URL=$(echo $RES | jq -r .upload.url) curl -s -X PUT --data-binary @$f "$URL" curl -s -X POST https://kms.airi.live/v1/storage/files/$ID/complete \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}' done # El enlace del manifiesto, firmado para toda la carpeta y por diez minutos. curl "https://kms.airi.live/v1/storage/files/$MANIFEST_ID/url\ ?expiresSeconds=600&directory=true" -H "Authorization: Bearer $TOKEN"
Un enlace por espectador
Con tu propio bucket
aws s3 sync ./protected s3://TU-BUCKET/pelicula-1234/ --acl public-read
Con Backblaze B2
B2 habla el mismo protocolo que S3, así que sirve la misma herramienta añadiendo --endpoint-url. Sale bastante más barato en almacenamiento y en transferencia, y es lo que usamos para nuestra propia demo.
export AWS_ACCESS_KEY_ID=<keyID> export AWS_SECRET_ACCESS_KEY=<applicationKey> aws s3 sync ./protected s3://TU-BUCKET/pelicula-1234/ \ --endpoint-url https://s3.us-west-002.backblazeb2.com
La región del endpoint (us-west-002 arriba) aparece en la ficha de tu bucket. El bucket tiene que ser de tipo Public, y la URL de reproducción queda así:
https://TU-BUCKET.s3.us-west-002.backblazeb2.com/pelicula-1234/manifest.mpd
Sube cada tipo de archivo con su Content-Type
application/octet-stream, algunos reproductores lo rechazan sin decir por qué. aws s3 sync acierta con los .mp4 pero no siempre con .mpd y .m3u8, así que conviene forzarlo.S3="s3://TU-BUCKET/pelicula-1234/" EP="--endpoint-url https://s3.us-west-002.backblazeb2.com" aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.mp4" \ --content-type video/mp4 aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.mpd" \ --content-type application/dash+xml aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.m3u8" \ --content-type application/vnd.apple.mpegurl
Dos cosas del bucket que hay que acertar
CORS. El reproductor descarga el manifiesto y los segmentos desde el navegador, así que el bucket tiene que permitirlo. Si falta, no carga nada y el error parece de DRM sin serlo.
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["Content-Length", "Content-Range"] }]
Peticiones por rango. El streaming depende de ellas. S3 las admite de serie, pero un CDN por delante puede no hacerlo, y el síntoma es un vídeo que no arranca nunca.
Reproducirlo #
- DASH
https://TU-BUCKET.s3.amazonaws.com/pelicula-1234/manifest.mpd- HLS
https://TU-BUCKET.s3.amazonaws.com/pelicula-1234/master.m3u8- Widevine
https://drm.airi.live/v2/widevine- PlayReady
https://drm.airi.live/v2/playready- FairPlay
https://drm.airi.live/fairplay
La configuración concreta para Shaka Player y Video.js está en Reproductores.
FairPlay necesita algo más