mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 07:12:36 -05:00
303 lines
7.8 KiB
TypeScript
303 lines
7.8 KiB
TypeScript
/**
|
|
* Fixture downloads: the raw captured frame as PNG, and for a detection an
|
|
* expected.json prefilled from the detector's output, so labeling is review-and-correct.
|
|
*/
|
|
|
|
import {
|
|
DEATH_EVENT_TYPE,
|
|
type DeathData,
|
|
} from "../core/detectors/death/index";
|
|
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
|
|
import {
|
|
MAP_START_EVENT_TYPE,
|
|
type MapStartData,
|
|
} from "../core/detectors/map-start/index";
|
|
import {
|
|
MINIMAP_EVENT_TYPE,
|
|
type MinimapData,
|
|
} from "../core/detectors/minimap/index";
|
|
import {
|
|
OBJECTIVE_EVENT_TYPE,
|
|
type ObjectiveData,
|
|
} from "../core/detectors/objective/index";
|
|
import {
|
|
PLAYER_STATUS_EVENT_TYPE,
|
|
type PlayerStatusData,
|
|
} from "../core/detectors/objective/player-status";
|
|
import {
|
|
STRIP_WEAPONS_EVENT_TYPE,
|
|
type StripWeaponsData,
|
|
} from "../core/detectors/objective/strip-weapons";
|
|
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
|
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
|
|
import {
|
|
SCOREBOARD_OWN_EVENT_TYPE,
|
|
type ScoreboardOwnData,
|
|
} from "../core/detectors/scoreboard-own/index";
|
|
import { mainWeaponLabel, stageLabel, weaponLabel } from "../core/labels";
|
|
import { downloadBlob as download } from "./download";
|
|
|
|
/** Scoreboard data with the replay extras present when the event has them. */
|
|
export type CardData = ScoreboardData &
|
|
Partial<Pick<ScoreboardBattleLogReplayData, "timestamp" | "replayCode">>;
|
|
|
|
/** Any detector's event payload that can prefill a fixture. */
|
|
export type FixtureData =
|
|
| CardData
|
|
| DeathData
|
|
| MapStartData
|
|
| ScoreboardOwnData
|
|
| MinimapData
|
|
| ObjectiveData
|
|
| PlayerStatusData
|
|
| StripWeaponsData
|
|
| KillData;
|
|
|
|
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
|
|
return eventType === DEATH_EVENT_TYPE;
|
|
}
|
|
|
|
function buildExpectedJson(
|
|
data: FixtureData | null,
|
|
eventType = "Scoreboard",
|
|
): string {
|
|
if (!data) {
|
|
return `${JSON.stringify({ event: "none" }, null, 2)}\n`;
|
|
}
|
|
if (isDeath(data, eventType)) {
|
|
const label = weaponLabel(data.weaponType, data.weaponId);
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
...(label !== null && { weaponLabel: label }),
|
|
...(data.weaponId !== null && { weaponId: data.weaponId }),
|
|
...(data.weaponType !== null && { weaponType: data.weaponType }),
|
|
abilities: data.abilities,
|
|
...(data.name !== null && { name: data.name }),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === SCOREBOARD_OWN_EVENT_TYPE) {
|
|
const own = data as ScoreboardOwnData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
...(own.lobby !== null && { lobby: own.lobby }),
|
|
...(own.mode !== null && { mode: own.mode }),
|
|
...(own.stage !== null && {
|
|
stage: own.stage,
|
|
stageLabel: stageLabel(own.stage),
|
|
}),
|
|
...(own.weaponId !== null && {
|
|
weaponLabel: mainWeaponLabel(own.weaponId),
|
|
weaponId: own.weaponId,
|
|
}),
|
|
abilities: own.abilities,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === MINIMAP_EVENT_TYPE) {
|
|
const minimap = data as MinimapData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
...(minimap.stage !== null && {
|
|
stage: minimap.stage,
|
|
stageLabel: stageLabel(minimap.stage),
|
|
}),
|
|
...(minimap.spectator && { spectator: true }),
|
|
teammates: minimap.teammates.map((p) => ({
|
|
self: p.self,
|
|
name: p.name,
|
|
weaponLabel: mainWeaponLabel(p.weaponId),
|
|
weaponId: p.weaponId,
|
|
abilities: p.abilities,
|
|
dead: p.dead,
|
|
specialReady: p.specialReady,
|
|
})),
|
|
enemies: minimap.enemies.map((p) => ({
|
|
...(minimap.spectator && { name: p.name }),
|
|
weaponLabel: mainWeaponLabel(p.weaponId),
|
|
weaponId: p.weaponId,
|
|
abilities: p.abilities,
|
|
dead: p.dead,
|
|
specialReady: p.specialReady,
|
|
})),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === OBJECTIVE_EVENT_TYPE) {
|
|
const objective = data as ObjectiveData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data:
|
|
objective.mode === "SZ"
|
|
? {
|
|
mode: objective.mode,
|
|
time: objective.time,
|
|
score: objective.score,
|
|
penalty: objective.penalty,
|
|
control: objective.control,
|
|
}
|
|
: {
|
|
mode: objective.mode,
|
|
time: objective.time,
|
|
score: objective.score,
|
|
control: objective.control,
|
|
position: objective.position,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === PLAYER_STATUS_EVENT_TYPE) {
|
|
const status = data as PlayerStatusData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
layout: status.layout,
|
|
cast: status.cast,
|
|
time: status.time,
|
|
special: status.special,
|
|
dead: status.dead,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === STRIP_WEAPONS_EVENT_TYPE) {
|
|
const strip = data as StripWeaponsData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
layout: strip.layout,
|
|
time: strip.time,
|
|
// the top candidate per slot; hand-correct to the true weapons
|
|
weapons: strip.slots.map((side) =>
|
|
side.map((candidates) => candidates?.[0]?.weaponId ?? null),
|
|
),
|
|
weaponLabels: strip.slots.map((side) =>
|
|
side.map((candidates) =>
|
|
mainWeaponLabel(candidates?.[0]?.weaponId ?? null),
|
|
),
|
|
),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
if (eventType === KILL_EVENT_TYPE) {
|
|
const kill = data as KillData;
|
|
return `${JSON.stringify(
|
|
{ event: eventType, data: { time: kill.time, names: kill.names } },
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
// NB: not a type-predicate helper — CardData is structurally assignable to
|
|
// MapStartData, so a predicate would narrow the fall-through case to never
|
|
if (eventType === MAP_START_EVENT_TYPE) {
|
|
const mapStart = data as MapStartData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
...(mapStart.mode !== null && { mode: mapStart.mode }),
|
|
...(mapStart.stage !== null && {
|
|
stage: mapStart.stage,
|
|
stageLabel: stageLabel(mapStart.stage),
|
|
}),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
const card = data as CardData;
|
|
return `${JSON.stringify(
|
|
{
|
|
event: eventType,
|
|
data: {
|
|
...(card.lobby !== null && { lobby: card.lobby }),
|
|
...(card.mode !== null && { mode: card.mode }),
|
|
...(card.stage !== null && {
|
|
stage: card.stage,
|
|
stageLabel: stageLabel(card.stage),
|
|
}),
|
|
...(card.timestamp != null && { timestamp: card.timestamp }),
|
|
...(card.replayCode != null && { replayCode: card.replayCode }),
|
|
matchScores: card.matchScores,
|
|
players: card.players.map((p) => ({
|
|
name: p.name,
|
|
weaponId: p.weaponId,
|
|
paint: p.paint,
|
|
ka: p.ka,
|
|
d: p.d,
|
|
s: p.s,
|
|
})),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
}
|
|
|
|
/** expected.json alone, for a frame already on disk (Screenshot page). Null data = negative-fixture form. */
|
|
export function downloadExpectedJson(
|
|
data: FixtureData | null,
|
|
eventType?: string,
|
|
): void {
|
|
download(
|
|
"expected.json",
|
|
new Blob([buildExpectedJson(data, eventType)], {
|
|
type: "application/json",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Fixture export for a live detection: the stored PNG is the byte-exact analyzed frame plus its parse output. */
|
|
export function saveFixtureFromEvent(
|
|
frame: Blob,
|
|
data: FixtureData,
|
|
eventType: string,
|
|
): void {
|
|
download("frame.png", frame);
|
|
download(
|
|
"expected.json",
|
|
new Blob([buildExpectedJson(data, eventType)], {
|
|
type: "application/json",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** The video's current frame as frame.png, the fixture's raw input. */
|
|
export async function saveFrame(video: HTMLVideoElement): Promise<void> {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
canvas.getContext("2d")!.drawImage(video, 0, 0);
|
|
const blob = await new Promise<Blob | null>((resolve) =>
|
|
canvas.toBlob(resolve, "image/png"),
|
|
);
|
|
if (!blob) throw new Error("could not encode frame");
|
|
download("frame.png", blob);
|
|
}
|