Skip to content
5 changes: 5 additions & 0 deletions src/matches/clips/clips.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ export class ClipsService {
);

try {
const outroEnv = await this.gameStreamer.resolveOutroBranding(
dims,
spec.output.fps,
);
await this.gameStreamer.dispatchClipRenderToPod(session.id, {
job_id: jobId,
token: sessionToken,
Expand All @@ -201,6 +205,7 @@ export class ClipsService {
})),
output_dims: dims,
output_fps: spec.output.fps,
outro_env: outroEnv,
});
} catch (error) {
this.logger.error(
Expand Down
84 changes: 84 additions & 0 deletions src/matches/game-streamer/game-streamer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import { GameStreamerStatusDto } from "./types/GameStreamerStatusDto";
import { AppConfig } from "../../configs/types/AppConfig";
import { SteamConfig } from "../../configs/types/SteamConfig";
import { resolveInClusterApiBase } from "../clips/clips.constants";
import { S3Service } from "../../s3/s3.service";
import {
DEFAULT_OUTRO_ACCENT,
computeOutroVersion,
outroCacheKey,
buildOutroEnv,
} from "./outro-branding";
import { LoggingService } from "../../k8s/logging/logging.service";
import {
SteamAccountService,
Expand Down Expand Up @@ -140,6 +147,7 @@ export class GameStreamerService {
private readonly demoMetadata: DemoMetadataService,
private readonly loggingService: LoggingService,
private readonly steamAccounts: SteamAccountService,
private readonly s3: S3Service,
) {
this.gameServerConfig = this.config.get<GameServersConfig>("gameServers");
this.appConfig = this.config.get<AppConfig>("app");
Expand Down Expand Up @@ -323,6 +331,64 @@ export class GameStreamerService {
return value === "false" || value === "0" ? "0" : "1";
}

// Branded outro env for the render pod. Active only when a custom logo is
// set. Returns {} (stock outro), a presigned cache URL (hit), or a render
// instruction + presigned PUT + branding props (miss). Best-effort: any
// failure returns {} so the pod falls back to the baked stock outro.
public async resolveOutroBranding(
dims: string,
fps: number,
): Promise<Record<string, string>> {
try {
const logoPath = await this.readSetting("public.logo_url");
if (!logoPath) {
return {};
}
// Empty brandName (no public.brand_name) is intentional: the pod renders
// a logo-only outro (the Outro composition hides the wordmark). Keep the
// CLIP_BRAND_NAME key with an empty value — do not drop it.
const brandName = (await this.readSetting("public.brand_name")) ?? "";
const accent =
(await this.readSetting("public.color_dark_tactical_amber")) ??
(await this.readSetting("public.color_tactical_amber")) ??
DEFAULT_OUTRO_ACCENT;

let etag = logoPath;
try {
etag = (await this.s3.stat(logoPath))?.etag ?? logoPath;
} catch {
/* keep logoPath as the version seed */
}

const version = computeOutroVersion({ brandName, accent, etag });
const key = outroCacheKey({ version, dims, fps });

if (await this.s3.has(key)) {
return buildOutroEnv({
hit: true,
cacheUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "get"),
});
}
return buildOutroEnv({
hit: false,
putUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "put"),
logoUrl: await this.s3.getPresignedUrl(
logoPath,
undefined,
3600,
"get",
),
brandName,
accent,
});
} catch (error) {
this.logger.warn(
`resolveOutroBranding failed: ${(error as Error)?.message ?? error}`,
);
return {};
}
}

public async resolveClipFps(): Promise<30 | 60> {
const value =
(await this.readSetting("clip_fps")) ?? process.env.CLIP_FPS ?? "60";
Expand Down Expand Up @@ -750,6 +816,9 @@ export class GameStreamerService {
name: "CLIP_BAKE_BRANDING",
value: await this.resolveClipBakeBranding(),
},
// Trusted S3 presign origin for render-clip.mjs's outro-env URL allowlist
// (independent of the demo source, so faceit/external demos still brand).
{ name: "S3_PUBLIC_ORIGIN", value: this.appConfig.demosDomain },
];
if (options.roundTicks != null) {
env.push({
Expand Down Expand Up @@ -1028,6 +1097,7 @@ export class GameStreamerService {
}>;
output_dims: string;
output_fps: number;
outro_env?: Record<string, string>;
},
) {
const url = this.getDemoSpecUrl(sessionId, "render-clip", "demo");
Expand Down Expand Up @@ -2139,6 +2209,20 @@ export class GameStreamerService {
name: "CLIP_BAKE_BRANDING",
value: await this.resolveClipBakeBranding(),
});
{
// Batch jobs share the global clip resolution/fps, so the outro env is
// computed once at pod level (resolve_outro_file re-validates dims and
// falls back, so a mismatch is non-fatal).
const dims =
(await this.resolveClipResolution()) === "720p"
? "1280x720"
: "1920x1080";
const fps = await this.resolveClipFps();
const outroEnv = await this.resolveOutroBranding(dims, fps);
for (const [name, value] of Object.entries(outroEnv)) {
env.push({ name, value });
}
}
env.push(...(await this.buildNodeCs2OptionsEnv(nodeId)));

this.logger.log(
Expand Down
48 changes: 48 additions & 0 deletions src/matches/game-streamer/outro-branding.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
DEFAULT_OUTRO_ACCENT,
computeOutroVersion,
outroCacheKey,
buildOutroEnv,
} from "./outro-branding";

describe("outro-branding", () => {
it("default accent is the stock amber triple", () => {
expect(DEFAULT_OUTRO_ACCENT).toBe("33 94% 58%");
});

it("version is deterministic and 12 chars", () => {
const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" });
const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" });
expect(a).toBe(b);
expect(a).toHaveLength(12);
});

it("version changes when the logo etag changes", () => {
const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" });
const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e2" });
expect(a).not.toBe(b);
});

it("cache key embeds version + dims + fps", () => {
expect(outroCacheKey({ version: "abc123abc123", dims: "1920x1080", fps: 60 }))
.toBe("branding/outro_abc123abc123_1920x1080_60.mp4");
});

it("hit env is just the cache URL", () => {
expect(buildOutroEnv({ hit: true, cacheUrl: "http://s3/x.mp4" }))
.toEqual({ CLIP_OUTRO_URL: "http://s3/x.mp4" });
});

it("miss env carries render instruction + branding props", () => {
expect(buildOutroEnv({
hit: false, putUrl: "http://put", logoUrl: "http://logo",
brandName: "ACME", accent: "33 94% 58%",
})).toEqual({
CLIP_OUTRO_RENDER: "1",
CLIP_OUTRO_PUT_URL: "http://put",
CLIP_BRAND_LOGO_URL: "http://logo",
CLIP_BRAND_NAME: "ACME",
CLIP_BRAND_ACCENT: "33 94% 58%",
});
});
});
51 changes: 51 additions & 0 deletions src/matches/game-streamer/outro-branding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";

export const DEFAULT_OUTRO_ACCENT = "33 94% 58%";

export function computeOutroVersion(parts: {
brandName: string;
accent: string;
etag: string;
}): string {
return createHash("sha1")
.update(`${parts.brandName}|${parts.accent}|${parts.etag}`)
.digest("hex")
.slice(0, 12);
}

export function outroCacheKey(args: {
version: string;
dims: string;
fps: number;
}): string {
return `branding/outro_${args.version}_${args.dims}_${args.fps}.mp4`;
}

export interface OutroEnvHit {
hit: true;
cacheUrl: string;
}
export interface OutroEnvMiss {
hit: false;
putUrl: string;
logoUrl: string;
brandName: string;
accent: string;
}
export type OutroEnvState = OutroEnvHit | OutroEnvMiss;

// The api's tsconfig has strict mode off, so a boolean discriminant does not
// narrow the union in the else branch — cast to the concrete variant instead.
export function buildOutroEnv(state: OutroEnvState): Record<string, string> {
if (state.hit) {
return { CLIP_OUTRO_URL: (state as OutroEnvHit).cacheUrl };
}
const miss = state as OutroEnvMiss;
return {
CLIP_OUTRO_RENDER: "1",
CLIP_OUTRO_PUT_URL: miss.putUrl,
CLIP_BRAND_LOGO_URL: miss.logoUrl,
CLIP_BRAND_NAME: miss.brandName,
CLIP_BRAND_ACCENT: miss.accent,
};
}