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 @@ -217,6 +217,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 @@ -229,6 +233,7 @@ export class ClipsService {
})),
output_dims: dims,
output_fps: spec.output.fps,
outro_env: outroEnv,
});
} catch (error) {
this.logger.error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("GameStreamerService — nade previews", () => {
{} as any,
steamAccounts as any,
{ resolveDefault: jest.fn().mockResolvedValue(null) } as any,
{} as any,
);
});

Expand Down
80 changes: 79 additions & 1 deletion src/matches/game-streamer/game-streamer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ jest.mock("@kubernetes/client-node", () => ({
}));

import { GameStreamerService } from "./game-streamer.service";
import {
DEFAULT_OUTRO_ACCENT,
computeOutroVersion,
outroCacheKey,
} from "./outro-branding";

describe("GameStreamerService", () => {
let service: GameStreamerService;
Expand All @@ -16,14 +21,23 @@ describe("GameStreamerService", () => {
resolveEnabled: jest.Mock;
bundleUrl: jest.Mock;
};
let s3: {
stat: jest.Mock;
has: jest.Mock;
getPresignedUrl: jest.Mock;
getPresignedUrlOrigin: jest.Mock;
};

const config = {
get: (key: string) => {
if (key === "gameServers") {
return { namespace: "test" };
}
if (key === "app") {
return { relayDomain: "https://tv.example.test" };
return {
relayDomain: "https://tv.example.test",
demosDomain: "https://demos.example.test",
};
}
return {} as any;
},
Expand All @@ -37,6 +51,12 @@ describe("GameStreamerService", () => {
resolveEnabled: jest.fn(),
bundleUrl: jest.fn().mockResolvedValue(null),
};
s3 = {
stat: jest.fn(),
has: jest.fn(),
getPresignedUrl: jest.fn(),
getPresignedUrlOrigin: jest.fn(),
};

service = new GameStreamerService(
logger as any,
Expand All @@ -48,6 +68,7 @@ describe("GameStreamerService", () => {
{} as any,
{} as any,
broadcastHuds as any,
s3 as any,
);
});

Expand Down Expand Up @@ -333,6 +354,63 @@ describe("GameStreamerService", () => {
});
});

describe("resolveS3PublicOrigin", () => {
// A remote store signs the outro URLs against its own host, so the
// demos domain would fail the pod's outro URL allowlist.
it("is the origin the outro urls are presigned against", async () => {
s3.getPresignedUrlOrigin.mockResolvedValue(
"https://5stack.s3.example.test",
);

await expect((service as any).resolveS3PublicOrigin()).resolves.toBe(
"https://5stack.s3.example.test",
);
});

it("falls back to the demos domain so the pod still starts", async () => {
s3.getPresignedUrlOrigin.mockRejectedValue(new Error("bad endpoint"));

await expect((service as any).resolveS3PublicOrigin()).resolves.toBe(
"https://demos.example.test",
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("bad endpoint"),
);
});
});

describe("resolveOutroBranding", () => {
// game-streamer drops the whole outro env for an accent that is not an
// HSL triple, so the stock amber is what gets rendered, and the version
// has to hash that accent rather than the setting.
it("renders and hashes the stock accent when the setting is not an HSL triple", async () => {
const settings: Record<string, string> = {
"public.logo_url": "branding/logo.png",
"public.brand_name": "Adria",
"public.color_dark_tactical_amber": "0 0% 0%) url(http://10.0.0.1/x",
};
hasura.query.mockImplementation(async (query: any) => ({
settings_by_pk: { value: settings[query.settings_by_pk.__args.name] },
}));
s3.stat.mockResolvedValue({ etag: "logo-etag" });
s3.has.mockResolvedValue(false);
s3.getPresignedUrl.mockImplementation(
async (key: string) => `https://s3.example.test/${key}`,
);

const env = await service.resolveOutroBranding("1920x1080", 60);

expect(env.CLIP_BRAND_ACCENT).toBe(DEFAULT_OUTRO_ACCENT);
const version = computeOutroVersion({
brandName: "Adria",
accent: DEFAULT_OUTRO_ACCENT,
etag: "logo-etag",
});
const key = outroCacheKey({ version, dims: "1920x1080", fps: 60 });
expect(env.CLIP_OUTRO_PUT_URL).toBe(`https://s3.example.test/${key}`);
});
});

describe("reportStatus", () => {
const streamSetOf = () =>
hasura.mutation.mock.calls[0][0].update_match_streams.__args._set;
Expand Down
113 changes: 113 additions & 0 deletions src/matches/game-streamer/game-streamer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ import {
BroadcastHud,
BroadcastHudsService,
} from "src/broadcast-huds/broadcast-huds.service";
import { S3Service } from "../../s3/s3.service";
import {
outroAccentFromSetting,
computeOutroVersion,
outroCacheKey,
buildOutroEnv,
sharedClipOutput,
} from "./outro-branding";
import { LoggingService } from "../../k8s/logging/logging.service";
import {
SteamAccountService,
Expand Down Expand Up @@ -177,6 +185,7 @@ export class GameStreamerService {
private readonly loggingService: LoggingService,
private readonly steamAccounts: SteamAccountService,
private readonly broadcastHuds: BroadcastHudsService,
private readonly s3: S3Service,
) {
this.gameServerConfig = this.config.get<GameServersConfig>("gameServers");
this.appConfig = this.config.get<AppConfig>("app");
Expand Down Expand Up @@ -370,6 +379,84 @@ 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 {};
}
// An empty brandName (no public.brand_name) keeps the stock 5STACK.gg
// wordmark and tagline next to the custom logo, the same fallback the
// web uses (brandName || "5Stack"). The empty name is still part of the
// version hash, so keep the CLIP_BRAND_NAME key with an empty value.
const brandName = (await this.readSetting("public.brand_name")) ?? "";
// The web themes from the dark palette only (the light-mode
// `public.color_*` rows are deleted at boot), so this is its accent.
// Anything but an HSL triple renders the stock amber, which is then
// what the version hash covers.
const accent = outroAccentFromSetting(
await this.readSetting("public.color_dark_tactical_amber"),
);

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 {};
}
}

// The origin render-clip.mjs accepts the outro URLs from. They are signed
// through getPresignedUrl, which uses the demos domain only for the
// in-cluster store; a remote store signs against its own host.
private async resolveS3PublicOrigin(): Promise<string> {
try {
return await this.s3.getPresignedUrlOrigin();
} catch (error) {
this.logger.warn(
`failed to resolve the S3 presign origin, using the demos domain: ${
(error as Error)?.message ?? error
}`,
);
return this.appConfig.demosDomain;
}
}

public async resolveClipFps(): Promise<30 | 60> {
const value =
(await this.readSetting(SystemSettingName.ClipFps)) ??
Expand Down Expand Up @@ -849,6 +936,11 @@ export class GameStreamerService {
name: "CLIP_BAKE_BRANDING",
value: await this.resolveClipBakeBranding(),
},
// Trusted origin for render-clip.mjs's outro-env URL allowlist: the
// origin resolveOutroBranding's presigned URLs really carry (a remote
// store signs against its own host, not the demos domain). Independent
// of the demo source, so faceit/external demos still brand.
{ name: "S3_PUBLIC_ORIGIN", value: await this.resolveS3PublicOrigin() },
];
if (options.roundTicks != null) {
env.push({
Expand Down Expand Up @@ -1140,6 +1232,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 @@ -2388,6 +2481,26 @@ export class GameStreamerService {
name: "CLIP_BAKE_BRANDING",
value: await this.resolveClipBakeBranding(),
});
{
// The pod gets one outro env, but each job renders at its own spec
// output (a re-queued job keeps the output it was created with). Key
// the outro on the output all the jobs share so it matches every clip
// it is appended to; when they differ, keep the stock outro.
const output = sharedClipOutput(jobs.map((j) => j.spec));
if (output) {
const outroEnv = await this.resolveOutroBranding(
output.dims,
output.fps,
);
for (const [name, value] of Object.entries(outroEnv)) {
env.push({ name, value });
}
} else {
this.logger.warn(
`[batch-highlights ${matchMapId}] jobs mix clip outputs, using the stock outro`,
);
}
}
env.push(...(await this.buildNodeCs2OptionsEnv(nodeId)));

this.logger.log(
Expand Down
Loading
Loading