diff --git a/motion/src/Outro.tsx b/motion/src/Outro.tsx index 327f0e7..4254d0e 100644 --- a/motion/src/Outro.tsx +++ b/motion/src/Outro.tsx @@ -34,6 +34,9 @@ export const outroSchema = z.object({ height: z.number().int().positive().default(1080), fps: z.number().int().positive().default(60), durationS: z.number().positive().default(3), + logoUrl: z.string().optional(), + brandName: z.string().optional(), + accent: z.string().optional(), }); export type OutroProps = z.infer; @@ -47,9 +50,12 @@ export const DEFAULT_OUTRO_PROPS: OutroProps = { const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3); -export const Outro: React.FC = ({ durationS }) => { +export const Outro: React.FC = ({ durationS, logoUrl, brandName, accent }) => { const frame = useCurrentFrame(); const { fps, width, height } = useVideoConfig(); + const ACCENT = accent ?? "33 94% 58%"; + const acc = (a: number) => `hsl(${ACCENT} / ${a})`; + const accSolid = `hsl(${ACCENT})`; const t = frame / fps; const totalFrames = Math.round(durationS * fps); @@ -318,6 +324,12 @@ export const Outro: React.FC = ({ durationS }) => { to: 1, }); + const WORD = (brandName ?? "").trim().toUpperCase().slice(0, 14) || null; + // Auto-fit: shrink titleSize for long names so they never overflow. + const wordTitleSize = WORD + ? Math.min(titleSize, Math.round((width * 0.82) / Math.max(WORD.length, 1) / 0.62)) + : titleSize; + return ( = ({ durationS }) => { top: logoY - logoSize * 0.4, width: logoSize * 1.6, height: logoSize * 1.6, - background: `conic-gradient(from ${180 + Math.sin(t * 0.5) * 6}deg at 50% 0%, transparent 165deg, hsl(33, 94%, 58%, 0.18) 175deg, hsl(33, 94%, 58%, 0.32) 180deg, hsl(33, 94%, 58%, 0.18) 185deg, transparent 195deg)`, + background: `conic-gradient(from ${180 + Math.sin(t * 0.5) * 6}deg at 50% 0%, transparent 165deg, ${acc(0.18)} 175deg, ${acc(0.32)} 180deg, ${acc(0.18)} 185deg, transparent 195deg)`, opacity: bgFade * 0.85, mixBlendMode: "screen", filter: "blur(14px)", @@ -357,7 +369,7 @@ export const Outro: React.FC = ({ durationS }) => { style={{ position: "absolute", inset: 0, - background: `radial-gradient(ellipse 60% 45% at 50% 46%, hsl(33, 94%, 58%, ${0.16 + radialPulse * 0.06}) 0%, transparent 70%)`, + background: `radial-gradient(ellipse 60% 45% at 50% 46%, ${acc(0.16 + radialPulse * 0.06)} 0%, transparent 70%)`, opacity: bgFade, mixBlendMode: "screen", }} @@ -384,9 +396,9 @@ export const Outro: React.FC = ({ durationS }) => { width: p.size, height: p.size, borderRadius: "50%", - background: BRAND.amber, + background: accSolid, opacity: p.opacity, - boxShadow: `0 0 ${p.size * 4}px ${BRAND.amber}`, + boxShadow: `0 0 ${p.size * 4}px ${accSolid}`, pointerEvents: "none", }} /> @@ -399,8 +411,8 @@ export const Outro: React.FC = ({ durationS }) => { left: leftBladeX, width: leftBladeWidth, height: bladeH, - background: `linear-gradient(90deg, transparent 0%, hsl(33, 94%, 58%, 0.35) 35%, ${BRAND.amber} 92%, #fff 100%)`, - boxShadow: `0 0 18px ${BRAND.amber}, 0 0 4px #fff`, + background: `linear-gradient(90deg, transparent 0%, ${acc(0.35)} 35%, ${accSolid} 92%, #fff 100%)`, + boxShadow: `0 0 18px ${accSolid}, 0 0 4px #fff`, opacity: bladeOpacity, }} /> @@ -411,8 +423,8 @@ export const Outro: React.FC = ({ durationS }) => { left: rightBladeX, width: rightBladeWidth, height: bladeH, - background: `linear-gradient(90deg, #fff 0%, ${BRAND.amber} 8%, hsl(33, 94%, 58%, 0.35) 65%, transparent 100%)`, - boxShadow: `0 0 18px ${BRAND.amber}, 0 0 4px #fff`, + background: `linear-gradient(90deg, #fff 0%, ${accSolid} 8%, ${acc(0.35)} 65%, transparent 100%)`, + boxShadow: `0 0 18px ${accSolid}, 0 0 4px #fff`, opacity: bladeOpacity, }} /> @@ -453,12 +465,12 @@ export const Outro: React.FC = ({ durationS }) => { opacity: logoOpacity, transform: `scale(${logoFinalScale})`, transformOrigin: "center center", - filter: `drop-shadow(0 0 ${24 * rimGlow}px hsl(33, 94%, 58%, ${0.55 * rimGlow})) drop-shadow(0 12px 28px rgba(0,0,0,0.6))`, + filter: `drop-shadow(0 0 ${24 * rimGlow}px ${acc(0.55 * rimGlow)}) drop-shadow(0 12px 28px rgba(0,0,0,0.6))`, }} > @@ -472,7 +484,7 @@ export const Outro: React.FC = ({ durationS }) => { left: 0, width: "100%", textAlign: "center", - fontSize: titleSize, + fontSize: WORD ? wordTitleSize : titleSize, fontWeight: 900, letterSpacing: "0.08em", lineHeight: 1, @@ -480,128 +492,159 @@ export const Outro: React.FC = ({ durationS }) => { transform: `scale(${wordmarkBreath})`, transformOrigin: "center center", textShadow: - "0 6px 22px rgba(0,0,0,0.7), 0 0 28px hsl(33, 94%, 58%, 0.18)", + `0 6px 22px rgba(0,0,0,0.7), 0 0 28px ${acc(0.18)}`, }} > - {STACK_CHARS.map((char, i) => { - const start = WORDMARK_START + i * LETTER_STAGGER - DECRYPT_PRE_T; - const resolve = WORDMARK_START + i * LETTER_STAGGER + LETTER_DURATION; - const cycling = t >= start && t < resolve; - const resolved = t >= resolve; - // Inline-block so each char can carry its own - // opacity/textShadow. Natural width — letter-spacing on - // the parent gives consistent visual gaps between - // chars regardless of glyph width. - const cellStyle: React.CSSProperties = { - display: "inline-block", - }; - if (!cycling && !resolved) { - return ( - - {char} - - ); - } - let display: string; - if (cycling) { - const cycleIdx = Math.floor((t - start) * fps / 2); - const idx = Math.floor( - random(`decrypt-${i}-${cycleIdx}`) * GLYPH_POOL.length, - ); - display = GLYPH_POOL[idx]; - } else { - display = char; - } - const resolveFlash = interpolate( - t, - [resolve - 0.01, resolve + 0.02, resolve + 0.22], - [0, 1, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, - ); - return ( - 0.001 - ? `0 0 ${30 * resolveFlash}px ${BRAND.amber}, 0 0 ${60 * resolveFlash}px hsl(33, 94%, 58%, ${0.7 * resolveFlash})` - : cycling - ? `0 0 12px hsl(33, 94%, 58%, 0.6)` - : undefined, - }} - > - {display} - - ); - })} - - {/* .gg — bullets in scaled, then each char decrypts in - sequence. The last 'g' is the LOCK moment. */} - - {[".", "g", "g"].map((char, i) => { - const resolve = GG_RESOLVE_TIMES[i]; - const cycling = t >= GG_TRIGGER_T && t < resolve; - const resolved = t >= resolve; - // Default to the natural char so the layout box is - // always reserved — prevents "5STACK" from snapping - // leftward when .gg first appears. The parent span's - // opacity/scale handle the visual entry. - let display: string = char; - if (cycling) { - const cycleIdx = Math.floor((t - GG_TRIGGER_T) * fps / 2); - const idx = Math.floor( - random(`gg-${i}-${cycleIdx}`) * GLYPH_POOL.length, + {WORD + ? WORD.split("").map((char, i) => { + const start = WORDMARK_START + i * LETTER_STAGGER - DECRYPT_PRE_T; + const resolve = WORDMARK_START + i * LETTER_STAGGER + LETTER_DURATION; + const cycling = t >= start && t < resolve; + const resolved = t >= resolve; + if (!cycling && !resolved) { + return {char}; + } + let display = char; + if (cycling) { + const cycleIdx = Math.floor(((t - start) * fps) / 2); + display = GLYPH_POOL[Math.floor(random(`decrypt-${i}-${cycleIdx}`) * GLYPH_POOL.length)]; + } + const resolveFlash = interpolate(t, [resolve - 0.01, resolve + 0.02, resolve + 0.22], [0, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + 0.001 + ? `0 0 ${30 * resolveFlash}px ${accSolid}, 0 0 ${60 * resolveFlash}px ${acc(0.7 * resolveFlash)}` + : cycling ? `0 0 12px ${acc(0.6)}` : undefined, + }}>{display} ); - display = GLYPH_POOL[idx]; - } - const isLast = i === GG_RESOLVE_TIMES.length - 1; - const resolveFlash = interpolate( - t, - [resolve - 0.01, resolve + 0.04, resolve + 0.3], - [0, 1, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, - ); - const flashScale = isLast ? 2.0 : 1.0; - // Narrower cell for the lowercase ".gg" chars, - // and centred so the glyph swap doesn't jitter. - return ( + }) + : (/* existing 5STACK chars + .gg span — UNCHANGED stock path */ + <> + {STACK_CHARS.map((char, i) => { + const start = WORDMARK_START + i * LETTER_STAGGER - DECRYPT_PRE_T; + const resolve = WORDMARK_START + i * LETTER_STAGGER + LETTER_DURATION; + const cycling = t >= start && t < resolve; + const resolved = t >= resolve; + // Inline-block so each char can carry its own + // opacity/textShadow. Natural width — letter-spacing on + // the parent gives consistent visual gaps between + // chars regardless of glyph width. + const cellStyle: React.CSSProperties = { + display: "inline-block", + }; + if (!cycling && !resolved) { + return ( + + {char} + + ); + } + let display: string; + if (cycling) { + const cycleIdx = Math.floor((t - start) * fps / 2); + const idx = Math.floor( + random(`decrypt-${i}-${cycleIdx}`) * GLYPH_POOL.length, + ); + display = GLYPH_POOL[idx]; + } else { + display = char; + } + const resolveFlash = interpolate( + t, + [resolve - 0.01, resolve + 0.02, resolve + 0.22], + [0, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + return ( + 0.001 + ? `0 0 ${30 * resolveFlash}px ${accSolid}, 0 0 ${60 * resolveFlash}px ${acc(0.7 * resolveFlash)}` + : cycling + ? `0 0 12px ${acc(0.6)}` + : undefined, + }} + > + {display} + + ); + })} + + {/* .gg — bullets in scaled, then each char decrypts in + sequence. The last 'g' is the LOCK moment. */} 0.001 - ? `0 0 ${36 * resolveFlash * flashScale}px ${BRAND.amber}, 0 0 ${80 * resolveFlash * flashScale}px hsl(33, 94%, 58%, ${0.75 * resolveFlash})` - : cycling - ? `0 0 18px hsl(33, 94%, 58%, 0.7), 0 0 36px hsl(33, 94%, 58%, 0.35)` - : `0 0 28px hsl(33, 94%, 58%, ${0.55 + 0.45 * bulletFlashOpacity}), 0 0 12px hsl(33, 94%, 58%, ${0.4 + 0.6 * bulletFlashOpacity})`, + opacity: ggOpacity, + transform: `scale(${0.3 + 0.7 * ggSpring})`, + transformOrigin: "left center", }} > - {display} + {[".", "g", "g"].map((char, i) => { + const resolve = GG_RESOLVE_TIMES[i]; + const cycling = t >= GG_TRIGGER_T && t < resolve; + const resolved = t >= resolve; + // Default to the natural char so the layout box is + // always reserved — prevents "5STACK" from snapping + // leftward when .gg first appears. The parent span's + // opacity/scale handle the visual entry. + let display: string = char; + if (cycling) { + const cycleIdx = Math.floor((t - GG_TRIGGER_T) * fps / 2); + const idx = Math.floor( + random(`gg-${i}-${cycleIdx}`) * GLYPH_POOL.length, + ); + display = GLYPH_POOL[idx]; + } + const isLast = i === GG_RESOLVE_TIMES.length - 1; + const resolveFlash = interpolate( + t, + [resolve - 0.01, resolve + 0.04, resolve + 0.3], + [0, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + const flashScale = isLast ? 2.0 : 1.0; + // Narrower cell for the lowercase ".gg" chars, + // and centred so the glyph swap doesn't jitter. + return ( + 0.001 + ? `0 0 ${36 * resolveFlash * flashScale}px ${accSolid}, 0 0 ${80 * resolveFlash * flashScale}px ${acc(0.75 * resolveFlash)}` + : cycling + ? `0 0 18px ${acc(0.7)}, 0 0 36px ${acc(0.35)}` + : `0 0 28px ${acc(0.55 + 0.45 * bulletFlashOpacity)}, 0 0 12px ${acc(0.4 + 0.6 * bulletFlashOpacity)}`, + }} + > + {display} + + ); + })} - ); - })} - + + )} {/* Bullet impact glow at GG_TRIGGER_T */} - {bulletFlashOpacity > 0.001 && ( + {!WORD && bulletFlashOpacity > 0.001 && (
= ({ durationS }) => { left: sparkX - titleSize * 0.5, width: titleSize, height: titleSize, - background: `radial-gradient(circle, rgba(255,255,255,0.9) 0%, hsl(33, 94%, 58%, 0.55) 30%, transparent 65%)`, + background: `radial-gradient(circle, rgba(255,255,255,0.9) 0%, ${acc(0.55)} 30%, transparent 65%)`, opacity: bulletFlashOpacity, mixBlendMode: "screen", pointerEvents: "none", @@ -629,79 +672,83 @@ export const Outro: React.FC = ({ durationS }) => { The . in "Yours." rhymes with the . in ".gg". ============================================================ */} - {/* Subtitle — fades in during the decrypt to set context */} -
- The System Behind the Game -
+ {!WORD && ( + <> + {/* Subtitle — fades in during the decrypt to set context */} +
+ The System Behind the Game +
- {/* YOURS. — Oxanium 900, all caps, matching the wordmark's - voice. Appears after .gg locks. The period pulses - synchronously with .gg locking as the brand's signature - punctuation moment. */} -
- Yours - 0.001 - ? `0 0 ${28 * periodGlow}px ${BRAND.amber}, 0 0 ${60 * periodGlow}px hsl(33, 94%, 58%, ${0.75 * periodGlow})` - : undefined, - color: - periodGlow > 0.001 - ? `hsl(33, 100%, ${58 + 22 * periodGlow}%)` - : BRAND.amber, - paddingLeft: "0.02em", - }} - > - . - -
+ {/* YOURS. — Oxanium 900, all caps, matching the wordmark's + voice. Appears after .gg locks. The period pulses + synchronously with .gg locking as the brand's signature + punctuation moment. */} +
+ Yours + 0.001 + ? `0 0 ${28 * periodGlow}px ${accSolid}, 0 0 ${60 * periodGlow}px ${acc(0.75 * periodGlow)}` + : undefined, + color: + periodGlow > 0.001 + ? `hsl(33, 100%, ${58 + 22 * periodGlow}%)` + : accSolid, + paddingLeft: "0.02em", + }} + > + . + +
+ + )} ); diff --git a/src/lib/inline-clip-render.sh b/src/lib/inline-clip-render.sh index 5a2ed83..857dcfa 100755 --- a/src/lib/inline-clip-render.sh +++ b/src/lib/inline-clip-render.sh @@ -8,6 +8,8 @@ SCRIPT_TAG=inline-clip . "$LIB_DIR/clip-capture.sh" # shellcheck disable=SC1091 . "$LIB_DIR/stream.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/outro.sh" require_env CLIP_RENDER_JOB_ID CLIP_RENDER_TOKEN STATUS_API_BASE \ SPEC_SERVER_URL @@ -702,18 +704,14 @@ CLIP_OUT_FILE="${CLIP_OUT_DIR}/${CLIP_RENDER_JOB_ID}.mp4" CLIP_THUMB_FILE="${CLIP_OUT_DIR}/${CLIP_RENDER_JOB_ID}.jpg" rm -f "$CLIP_OUT_FILE" "$CLIP_THUMB_FILE" -# Precompute: will an outro be appended at concat time? If yes AND we -# would have run a per-segment chip-overlay pass, we can fuse both into -# a single ffmpeg encode at the end — eliminating -# one full 1080p60 NVENC pass per clip. The polish-skip gate below -# reads OUTRO_WILL_APPEND; the fused encode reads it at concat time. +# Will an outro be appended at concat time? Cheap predicate only (no +# download/render) — the actual file (cache download / branded render / +# baked stock) is resolved at concat time via resolve_outro_file. The +# polish-skip gate below reads OUTRO_WILL_APPEND; the fused encode reads it +# at concat time. OUTRO_WILL_APPEND=0 -OUTRO_FUSED_FILE="" if [ "$BRANDING_ENABLED" = "1" ] && [ "${CLIP_DISABLE_OUTRO:-0}" != "1" ]; then - OUTRO_DIMS_PRE="${CLIP_OUTPUT_DIMS:-1920x1080}" - OUTRO_FPS_PRE="${CLIP_OUTPUT_FPS:-60}" - OUTRO_FUSED_FILE="${OUTRO_DIR:-/opt/game-streamer/resources/video}/outro_${OUTRO_DIMS_PRE}_${OUTRO_FPS_PRE}.mp4" - if [ -f "$OUTRO_FUSED_FILE" ]; then + if outro_will_append "${CLIP_OUTPUT_DIMS:-1920x1080}" "${CLIP_OUTPUT_FPS:-60}"; then OUTRO_WILL_APPEND=1 fi fi @@ -726,12 +724,25 @@ fi # plus a split-free concat. CLIP_MAX_FUSED_SEGMENTS="${CLIP_MAX_FUSED_SEGMENTS:-6}" WILL_FUSE_POLISH_OUTRO=0 +# Fuse (defer the chip to a single final encode) only when a baked outro for +# these dims/fps exists: that guarantees resolve_outro_file yields an existing +# file at concat (OUTRO_APPENDED=1) even if a branded download/render fails, so +# the deferred chip is always baked in the fused branch. For dims/fps with no +# baked fallback (e.g. 30fps), skip fusing — the chip is baked per-segment, +# which stays correct whether or not the branded outro ends up appended. if [ "$OUTRO_WILL_APPEND" = "1" ] \ + && outro_baked_exists "${CLIP_OUTPUT_DIMS:-1920x1080}" "${CLIP_OUTPUT_FPS:-60}" \ && [ -n "$CHIP_NAME" ] \ && [ "$SEG_COUNT" -le "$CLIP_MAX_FUSED_SEGMENTS" ]; then WILL_FUSE_POLISH_OUTRO=1 -elif [ "$OUTRO_WILL_APPEND" = "1" ] && [ -n "$CHIP_NAME" ]; then +elif [ "$OUTRO_WILL_APPEND" = "1" ] && [ -n "$CHIP_NAME" ] \ + && outro_baked_exists "${CLIP_OUTPUT_DIMS:-1920x1080}" "${CLIP_OUTPUT_FPS:-60}"; then + # baked fallback exists but segment count exceeds the fuse cap say "concat: ${SEG_COUNT} segments exceeds fuse cap ${CLIP_MAX_FUSED_SEGMENTS} — per-segment polish + split-free concat" +elif [ "$OUTRO_WILL_APPEND" = "1" ] && [ -n "$CHIP_NAME" ]; then + # no baked fallback for these dims/fps (e.g. 30fps): can't guarantee the fused + # branch runs, so bake the chip per-segment; branded outro still appended if it renders + say "concat: no baked outro for ${CLIP_OUTPUT_DIMS:-1920x1080}@${CLIP_OUTPUT_FPS:-60} — not fusing; chip baked per-segment" fi # Non-fused path bakes the chip per-segment INSIDE the capture loop, so it has to @@ -1276,16 +1287,14 @@ fi # and the Remotion outro pushes the outro ~30s past in stream-copy). OUTRO_APPENDED=0 if [ "$BRANDING_ENABLED" = "1" ] && [ "${CLIP_DISABLE_OUTRO:-0}" != "1" ]; then - OUTRO_DIMS="${CLIP_OUTPUT_DIMS:-1920x1080}" - OUTRO_FPS="${CLIP_OUTPUT_FPS:-60}" - OUTRO_FILE="${OUTRO_DIR:-/opt/game-streamer/resources/video}/outro_${OUTRO_DIMS}_${OUTRO_FPS}.mp4" + OUTRO_FILE="$(resolve_outro_file "${CLIP_OUTPUT_DIMS:-1920x1080}" "${CLIP_OUTPUT_FPS:-60}")" if [ -f "$OUTRO_FILE" ]; then say "OUTRO: appending $OUTRO_FILE" printf "file '%s'\n" "$OUTRO_FILE" >>"$SEG_DIR/concat.txt" SEG_COUNT=$((SEG_COUNT + 1)) OUTRO_APPENDED=1 else - say "OUTRO: missing $OUTRO_FILE — shipping without outro" + say "OUTRO: no outro available ($OUTRO_FILE) — shipping without outro" fi fi diff --git a/src/lib/outro.sh b/src/lib/outro.sh new file mode 100644 index 0000000..7b3286f --- /dev/null +++ b/src/lib/outro.sh @@ -0,0 +1,109 @@ +# shellcheck shell=bash +# Branded-outro resolution for inline-clip-render.sh. Sourced, not executed. +# Resolves a single local outro mp4: branded S3 cache (hit), freshly rendered +# + uploaded (miss), or the baked stock outro (inactive / any failure). +# Env contract (set by the api): CLIP_OUTRO_URL | CLIP_OUTRO_RENDER(+CLIP_OUTRO_PUT_URL) +# + CLIP_BRAND_LOGO_URL / CLIP_BRAND_NAME / CLIP_BRAND_ACCENT. + +_outro_baked_path() { + printf '%s/outro_%s_%s.mp4' "${OUTRO_DIR:-/opt/game-streamer/resources/video}" "$1" "$2" +} +# Object basename from a presigned URL (strips the query/signature). The S3 key +# embeds the branding version (outro___.mp4), so keying the +# in-pod cache on it prevents a stale outro after a mid-pod branding change. +_outro_url_basename() { + local u="${1%%\?*}" # strip query string + printf '%s' "${u##*/}" +} + +# --- Mockable wrappers (tests override these) --------------------------- +# No --show-error: a curl failure must not print the (presigned, signature-bearing) +# URL to pod logs; resolve_outro_file logs an opaque fallback message instead. +_outro_download() { curl --fail --silent --max-time 60 -o "$2" "$1"; } +_outro_upload() { curl --fail --silent --max-time 120 --upload-file "$1" "$2"; } +_outro_dims_ok() { + local got; got=$(ffprobe -v error -select_streams v -show_entries stream=width,height \ + -of csv=s=x:p=0 "$1" 2>/dev/null) + [ "$got" = "$2" ] +} +_outro_render() { + local dest="$1" dims="$2" fps="$3" logo="$4" name="$5" accent="$6" + local w="${dims%x*}" h="${dims#*x}" + local props + props=$(LOGO="$logo" NAME="$name" ACCENT="$accent" W="$w" H="$h" F="$fps" node -e \ + 'process.stdout.write(JSON.stringify({width:+process.env.W,height:+process.env.H,fps:+process.env.F,durationS:3,logoUrl:process.env.LOGO||undefined,brandName:process.env.NAME||undefined,accent:process.env.ACCENT||undefined}))') + local pin=() + if command -v taskset >/dev/null 2>&1 && declare -F compute_cpu_split >/dev/null 2>&1; then + compute_cpu_split; [ -n "${GS_CAPTURE_CPUS:-}" ] && pin=(taskset -c "$GS_CAPTURE_CPUS") + fi + ( cd "${MOTION_DIR:-/opt/game-streamer/motion}" && \ + ${pin[@]+"${pin[@]}"} nice -n 19 node node_modules/.bin/remotion render src/index.ts Outro "$dest" \ + --codec=h264 --pixel-format=yuv420p --log=error --props="$props" ) +} + +# --- Public API --------------------------------------------------------- +# 0 if an outro will be appended (cheap; no download/render), else 1. +outro_will_append() { + [ -n "${CLIP_OUTRO_URL:-}" ] && return 0 + [ "${CLIP_OUTRO_RENDER:-0}" = "1" ] && return 0 + [ -f "$(_outro_baked_path "$1" "$2")" ] && return 0 + return 1 +} + +# 0 if a baked stock outro for these dims/fps exists on disk, else 1. The fuse +# decision in inline-clip-render.sh gates on this: a baked fallback guarantees +# resolve_outro_file yields an existing file (so OUTRO_APPENDED stays 1) even if +# a branded download/render fails, so a deferred chip is always baked. +outro_baked_exists() { + [ -f "$(_outro_baked_path "$1" "$2")" ] +} + +# Prints the local outro mp4 path to append. Heavy — call once at concat time. +resolve_outro_file() { + local dims="$1" fps="$2" + local baked; baked="$(_outro_baked_path "$dims" "$fps")" + + # Inactive (no branding env) → baked stock outro. + if [ -z "${CLIP_OUTRO_URL:-}" ] && [ "${CLIP_OUTRO_RENDER:-0}" != "1" ]; then + printf '%s' "$baked"; return 0 + fi + + # Version-keyed in-pod cache filename, derived from the presigned URL's object + # basename (embeds the branding version) so a mid-pod branding change is not + # masked by a stale dims/fps-only file. Falls back to dims/fps if unparseable. + local cache_dir="${CLIP_OUT_DIR:-/tmp/game-streamer/clips}/branded-outro" + local fname; fname="$(_outro_url_basename "${CLIP_OUTRO_URL:-${CLIP_OUTRO_PUT_URL:-}}")" + # Only a plain outro_.mp4 name may be used as-is; a basename with any + # metacharacter (which could break the ffmpeg concat list) falls back to the + # fixed dims/fps name. + if [[ "$fname" =~ ^outro_[A-Za-z0-9._-]+\.mp4$ ]]; then + : + else + fname="outro_${dims}_${fps}.mp4" + fi + local cached="$cache_dir/$fname" + + [ -f "$cached" ] && { printf '%s' "$cached"; return 0; } # in-pod cache (prior clip, same version) + + if [ -n "${CLIP_OUTRO_URL:-}" ]; then + mkdir -p "$cache_dir" + if _outro_download "$CLIP_OUTRO_URL" "$cached" && _outro_dims_ok "$cached" "$dims"; then + printf '%s' "$cached"; return 0 + fi + say "OUTRO: branded cache download failed/mismatch — using baked stock" + rm -f "$cached"; printf '%s' "$baked"; return 0 + fi + + # CLIP_OUTRO_RENDER=1 + mkdir -p "$cache_dir" + if _outro_render "$cached" "$dims" "$fps" \ + "${CLIP_BRAND_LOGO_URL:-}" "${CLIP_BRAND_NAME:-}" "${CLIP_BRAND_ACCENT:-}" \ + && _outro_dims_ok "$cached" "$dims"; then + if [ -n "${CLIP_OUTRO_PUT_URL:-}" ]; then + _outro_upload "$cached" "$CLIP_OUTRO_PUT_URL" || say "OUTRO: cache upload failed (non-fatal)" + fi + printf '%s' "$cached"; return 0 + fi + say "OUTRO: branded render failed — using baked stock" + rm -f "$cached"; printf '%s' "$baked"; return 0 +} diff --git a/src/lib/outro.test.sh b/src/lib/outro.test.sh new file mode 100644 index 0000000..e379cf4 --- /dev/null +++ b/src/lib/outro.test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Pure-bash harness for outro.sh. Stubs the _outro_* wrappers so no real +# curl/remotion/ffprobe runs. Run: bash src/lib/outro.test.sh +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +export CLIP_OUT_DIR="$TMP/clips" +export OUTRO_DIR="$TMP/baked" +CACHE="$CLIP_OUT_DIR/branded-outro" +mkdir -p "$OUTRO_DIR" +fails=0 +say() { :; } # silence lib logging +ok() { if [ "$1" = "$2" ]; then echo "PASS: $3"; else echo "FAIL: $3 (got '$1' want '$2')"; fails=$((fails+1)); fi; } + +# shellcheck disable=SC1090 +. "$HERE/outro.sh" + +# Deterministic stubs (override the lib wrappers). +_outro_download() { mkdir -p "$(dirname "$2")"; : >"$2"; return 0; } # creates dest +_outro_render() { mkdir -p "$(dirname "$1")"; : >"$1"; return 0; } # creates dest +_outro_upload() { return 0; } +_outro_dims_ok() { return 0; } + +reset() { rm -rf "$CLIP_OUT_DIR"; unset CLIP_OUTRO_URL CLIP_OUTRO_RENDER CLIP_OUTRO_PUT_URL; } + +# 1) Inactive -> baked path +reset; : >"$OUTRO_DIR/outro_1920x1080_60.mp4" +ok "$(resolve_outro_file 1920x1080 60)" "$OUTRO_DIR/outro_1920x1080_60.mp4" "inactive returns baked" + +# 2) Hit -> cache file keyed on the presigned URL object basename (version-keyed) +reset; export CLIP_OUTRO_URL="https://s3/bucket/branding/outro_v1abc_1920x1080_60.mp4?X-Amz-Sig=z" +ok "$(resolve_outro_file 1920x1080 60)" "$CACHE/outro_v1abc_1920x1080_60.mp4" "hit returns version-keyed cache path" + +# 3) Miss -> render to version-keyed path (from the PUT url basename) +reset; export CLIP_OUTRO_RENDER=1 CLIP_OUTRO_PUT_URL="https://s3/bucket/branding/outro_v2def_1280x720_60.mp4?sig=z" +ok "$(resolve_outro_file 1280x720 60)" "$CACHE/outro_v2def_1280x720_60.mp4" "miss returns version-keyed render path" + +# 4) Render failure -> baked fallback +reset; : >"$OUTRO_DIR/outro_1280x720_60.mp4"; export CLIP_OUTRO_RENDER=1 CLIP_OUTRO_PUT_URL="https://s3/branding/outro_v9_1280x720_60.mp4?s=1" +_outro_render() { return 1; } +ok "$(resolve_outro_file 1280x720 60)" "$OUTRO_DIR/outro_1280x720_60.mp4" "render failure falls back to baked" +_outro_render() { mkdir -p "$(dirname "$1")"; : >"$1"; return 0; } + +# 5) Download dims-mismatch -> baked fallback +reset; : >"$OUTRO_DIR/outro_1920x1080_60.mp4"; export CLIP_OUTRO_URL="https://s3/branding/outro_v5_1920x1080_60.mp4?s=1" +_outro_dims_ok() { return 1; } +ok "$(resolve_outro_file 1920x1080 60)" "$OUTRO_DIR/outro_1920x1080_60.mp4" "download dims-mismatch falls back to baked" +_outro_dims_ok() { return 0; } + +# 6) Version-keying: a stale old-version cache file is NOT reused for a new version +reset; mkdir -p "$CACHE"; : >"$CACHE/outro_OLDVER_1920x1080_60.mp4" +export CLIP_OUTRO_URL="https://s3/branding/outro_NEWVER_1920x1080_60.mp4?s=1" +ok "$(resolve_outro_file 1920x1080 60)" "$CACHE/outro_NEWVER_1920x1080_60.mp4" "new branding version not masked by stale cache" + +# 7) Unparseable URL object name -> falls back to dims/fps cache key +reset; export CLIP_OUTRO_URL="https://s3/weird" +ok "$(resolve_outro_file 1920x1080 60)" "$CACHE/outro_1920x1080_60.mp4" "unparseable url -> dims/fps cache key" + +# 7b) Basename with metacharacters -> sanitized to the safe dims/fps name +reset; export CLIP_OUTRO_URL="https://s3/branding/outro_'evil_1920x1080_60.mp4?s=1" +ok "$(resolve_outro_file 1920x1080 60)" "$CACHE/outro_1920x1080_60.mp4" "metachar basename -> safe dims/fps name" + +# 8) outro_will_append predicate (baked / none / URL / RENDER) +reset; : >"$OUTRO_DIR/outro_1920x1080_60.mp4" +if outro_will_append 1920x1080 60; then ok 0 0 "baked exists -> will append"; else ok 1 0 "baked exists -> will append"; fi +rm -f "$OUTRO_DIR/outro_1920x1080_60.mp4" +if outro_will_append 1920x1080 60; then ok 0 1 "no baked, no env -> NOT append"; else ok 1 1 "no baked, no env -> NOT append"; fi +export CLIP_OUTRO_URL="https://s3/branding/outro_x_1920x1080_60.mp4?s=1" +if outro_will_append 1920x1080 60; then ok 0 0 "CLIP_OUTRO_URL set -> will append"; else ok 1 0 "CLIP_OUTRO_URL set -> will append"; fi +unset CLIP_OUTRO_URL; export CLIP_OUTRO_RENDER=1 +if outro_will_append 1920x1080 60; then ok 0 0 "CLIP_OUTRO_RENDER=1 -> will append"; else ok 1 0 "CLIP_OUTRO_RENDER=1 -> will append"; fi + +# 9) outro_baked_exists predicate (drives the fuse gate) +reset; : >"$OUTRO_DIR/outro_1920x1080_60.mp4" +if outro_baked_exists 1920x1080 60; then ok 0 0 "baked 60 exists -> true"; else ok 1 0 "baked 60 exists -> true"; fi +if outro_baked_exists 1920x1080 30; then ok 0 1 "no baked 30 -> false"; else ok 1 1 "no baked 30 -> false"; fi + +[ "$fails" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "$fails FAILED"; exit 1; } diff --git a/src/spectator/routes/render-clip.mjs b/src/spectator/routes/render-clip.mjs index 62e5a12..da353db 100644 --- a/src/spectator/routes/render-clip.mjs +++ b/src/spectator/routes/render-clip.mjs @@ -10,7 +10,8 @@ export async function renderClipHandler(_req, res, body) { const jobId = String(body.job_id ?? ""); const token = String(body.token ?? ""); const apiBase = String(body.api_base ?? ""); - const outputDims = String(body.output_dims ?? "1920x1080"); + const rawDims = String(body.output_dims ?? "1920x1080"); + const outputDims = /^\d{2,4}x\d{2,4}$/.test(rawDims) ? rawDims : "1920x1080"; const outputFps = Number.parseInt(body.output_fps, 10) || 60; // Multi-segment editor sends `segments`; older callers send a single @@ -46,6 +47,55 @@ export async function renderClipHandler(_req, res, body) { return; } + // Outro/branding env from the api. This endpoint is unauthenticated and the + // pod is host-networked, so the POST body is UNTRUSTED. Two gates: + // 1. key allowlist — only CLIP_OUTRO_*/CLIP_BRAND_* may reach the render env. + // 2. URL-value allowlist — the URL-bearing keys must share the S3/MinIO origin + // the api presigns against (taken from DEMO_URL). Without this, an attacker + // could point the logo (headless Chromium) or the curl PUT at an + // arbitrary internal URL (SSRF) / host (arbitrary write). A rejected URL key + // is dropped → the render falls back to the baked stock outro. + const URL_KEYS = new Set([ + "CLIP_OUTRO_URL", + "CLIP_OUTRO_PUT_URL", + "CLIP_BRAND_LOGO_URL", + ]); + // Prefer the api-provided S3 presign origin (trusted pod env, independent of + // the demo's source so faceit/external demos still brand); fall back to + // DEMO_URL's origin for pods created before S3_PUBLIC_ORIGIN existed. + let allowedOrigin = null; + try { + allowedOrigin = new URL( + process.env.S3_PUBLIC_ORIGIN || process.env.DEMO_URL, + ).origin; + } catch { + allowedOrigin = null; + } + const outroEnv = {}; + const outroSrc = + body.outro_env && typeof body.outro_env === "object" ? body.outro_env : {}; + for (const [k, v] of Object.entries(outroSrc)) { + if (!(k.startsWith("CLIP_OUTRO_") || k.startsWith("CLIP_BRAND_")) || v == null) { + continue; + } + if (URL_KEYS.has(k)) { + let sameOrigin = false; + try { + sameOrigin = + !!allowedOrigin && new URL(String(v)).origin === allowedOrigin; + } catch { + sameOrigin = false; + } + if (!sameOrigin) { + process.stderr.write( + `[spec-server] render-clip: rejected ${k} (not the S3 origin) — outro falls back to baked\n`, + ); + continue; + } + } + outroEnv[k] = String(v); + } + const child = spawn("bash", [`${SRC_DIR}/lib/inline-clip-render.sh`], { detached: true, stdio: ["ignore", "inherit", "inherit"], @@ -59,6 +109,7 @@ export async function renderClipHandler(_req, res, body) { CLIP_OUTPUT_FPS: String(outputFps), CLIP_TICK_RATE: String(demoState.tickRate || 64), SPEC_SERVER_URL: `http://127.0.0.1:${PORT}`, + ...outroEnv, }, }); child.unref();