Skip to content
Closed
107 changes: 81 additions & 26 deletions frontend/src/App.tsx

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export interface CountryFacet {
count: number;
}

export type MediaKind = "voice" | "sound" | "unknown";
export type MediaKind = "voice" | "sound" | "music" | "unknown";
export type MediaSort = "name_asc" | "name_desc" | "description_asc";

export interface MediaItem {
Expand Down Expand Up @@ -678,6 +678,7 @@ export const api = {
? staticAudioUrl(assetId)
: `/api/assets/${assetId}/media`,
videoUrl: (assetId: string) => `/api/assets/${assetId}/video.mp4`,
videoPosterUrl: (assetId: string) => `/api/assets/${assetId}/poster.png`,
entityPreviewUrl: (
sourceId: string,
entityId: string,
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/audioPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ interface AudioPlaybackState {
loading: boolean;
}

interface AudioProgressState {
assetId: string;
currentTime: number;
duration: number;
}

const listeners = new Set<() => void>();
const progressListeners = new Set<() => void>();
let playbackState: AudioPlaybackState = { assetId: "", playing: false, loading: false };
let progressState: AudioProgressState = { assetId: "", currentTime: 0, duration: 0 };
let sharedAudio: HTMLAudioElement | null = null;
let sourceUrl = "";

Expand All @@ -23,6 +31,27 @@ function publish(next: AudioPlaybackState) {
for (const listener of listeners) listener();
}

// Progress lives in its own channel so the frequent timeupdate tick only
// re-renders the progress bar instead of every play button on screen.
function publishProgress(next: AudioProgressState) {
if (
next.assetId === progressState.assetId
&& next.currentTime === progressState.currentTime
&& next.duration === progressState.duration
) return;
progressState = next;
for (const listener of progressListeners) listener();
}

function readProgress() {
if (!sharedAudio) return;
publishProgress({
assetId: playbackState.assetId,
currentTime: sharedAudio.currentTime,
duration: Number.isFinite(sharedAudio.duration) ? sharedAudio.duration : 0,
});
}

function audioElement() {
if (sharedAudio) return sharedAudio;
const audio = new Audio();
Expand All @@ -33,6 +62,9 @@ function audioElement() {
audio.addEventListener("pause", () => publish({ assetId: playbackState.assetId, playing: false, loading: false }));
audio.addEventListener("ended", () => publish({ assetId: playbackState.assetId, playing: false, loading: false }));
audio.addEventListener("error", () => publish({ assetId: playbackState.assetId, playing: false, loading: false }));
audio.addEventListener("timeupdate", readProgress);
audio.addEventListener("durationchange", readProgress);
audio.addEventListener("seeked", readProgress);
sharedAudio = audio;
return audio;
}
Expand All @@ -52,6 +84,21 @@ export function useAudioPlayback() {
return useSyncExternalStore(subscribeAudioPlayback, getAudioPlaybackState, getAudioPlaybackState);
}

export function getAudioProgress() {
return progressState;
}

export function subscribeAudioProgress(listener: () => void) {
progressListeners.add(listener);
return () => {
progressListeners.delete(listener);
};
}

export function useAudioProgress() {
return useSyncExternalStore(subscribeAudioProgress, getAudioProgress, getAudioProgress);
}

export function playAudioAsset(assetId: string, url: string) {
const audio = audioElement();
const cachedUrl = cachedAudioResourceUrl(url);
Expand All @@ -61,6 +108,7 @@ export function playAudioAsset(assetId: string, url: string) {
audio.pause();
sourceUrl = playableUrl;
publish({ assetId, playing: false, loading: true });
publishProgress({ assetId, currentTime: 0, duration: 0 });
audio.src = playableUrl;
audio.preload = "auto";
}
Expand All @@ -74,6 +122,12 @@ export function pauseAudioAsset(assetId?: string) {
sharedAudio?.pause();
}

export function seekAudioAsset(seconds: number) {
if (!sharedAudio || !Number.isFinite(sharedAudio.duration)) return;
sharedAudio.currentTime = Math.min(Math.max(seconds, 0), sharedAudio.duration);
readProgress();
}

export function toggleAudioAsset(assetId: string, url: string) {
const audio = audioElement();
if (playbackState.assetId === assetId && (!audio.paused || playbackState.loading)) {
Expand Down
97 changes: 60 additions & 37 deletions frontend/src/styles.css

Large diffs are not rendered by default.

60 changes: 55 additions & 5 deletions src/ra2_explorer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import re
import uuid
from collections.abc import Collection
from pathlib import Path
from typing import Literal
from urllib.parse import quote
Expand Down Expand Up @@ -53,6 +54,7 @@
BUNDLED_UNIT_VOICE_TRANSCRIPT_PATH,
BUNDLED_VOICE_TRANSLATION_PATH,
load_audio_transcript,
load_audio_translations,
load_known_names,
reference_status,
sync_known_names,
Expand Down Expand Up @@ -126,6 +128,12 @@ def __init__(self, settings: Settings):
BUNDLED_VOICE_TRANSLATION_PATH,
),
),
load_audio_translations(
supplement_paths=(
settings.mission_audio_transcript_path,
BUNDLED_VOICE_TRANSLATION_PATH,
)
),
)
self.video = VideoTranscoder(self.database, self.reader, self.derived)

Expand Down Expand Up @@ -381,7 +389,7 @@ def media(
limit: int = Query(default=500, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict[str, object]:
if kind is not None and kind not in {"voice", "sound", "unknown"}:
if kind is not None and kind not in {"voice", "sound", "music", "unknown"}:
raise HTTPException(status_code=422, detail="未知音频类型")
if sort not in {"name_asc", "name_desc", "description_asc"}:
raise HTTPException(status_code=422, detail="未知音频排序方式")
Expand Down Expand Up @@ -770,6 +778,7 @@ def asset_preview(
"tmp",
"pcx",
"map",
"vpl",
}:
raise HTTPException(status_code=409, detail="该格式没有图像预览")
artifact_path = _asset_artifact_path(
Expand All @@ -795,6 +804,8 @@ def asset_preview(
asset_record, data = services.reader.read(asset_id)
if asset_record["format"] == "pal":
image = parse_palette(data).preview(cell_size=max(4, scale * 3))
elif asset_record["format"] == "vpl":
image = parse_vpl(data).preview(cell_size=max(1, scale))
elif asset_record["format"] == "shp":
sprite = parse_shp(data)
if frame >= len(sprite.frames) or (
Expand All @@ -806,6 +817,7 @@ def asset_preview(
asset_record,
palette_id,
palette_kind,
used_indices=set(sprite.pixels(frame)),
)
if player_color:
palette = (palette or grayscale_palette()).with_player_color(player_color)
Expand Down Expand Up @@ -901,6 +913,15 @@ def asset_video(asset_id: str) -> FileResponse:
headers={"Cache-Control": "private, max-age=86400"},
)

@app.get("/api/assets/{asset_id}/poster.png")
def asset_poster(asset_id: str) -> FileResponse:
path = services.video.poster_frame(asset_id)
return FileResponse(
path,
media_type="image/png",
headers={"Cache-Control": "private, max-age=86400"},
)

@app.get("/api/palettes")
def palettes(source_id: str) -> list[dict[str, object]]:
return services.database.palette_assets(source_id)
Expand Down Expand Up @@ -1199,6 +1220,7 @@ def _select_palette(
asset: dict[str, object],
palette_id: str | None,
palette_kind: Literal["unit", "animation"] | None = None,
used_indices: Collection[int] | None = None,
):
palettes = services.database.palette_assets(str(asset["source_id"]))
if palette_id:
Expand Down Expand Up @@ -1245,17 +1267,45 @@ def _select_palette(
else [f"unit{theater}.pal", "unittem.pal", f"iso{theater}.pal", "isotem.pal"]
)
priority = {name: index for index, name in enumerate(preferred)}
palette_asset = min(
palettes,
candidates = sorted(
(item for item in palettes if str(item["display_name"]).lower() in priority),
key=lambda item: (
priority.get(str(item["display_name"]).lower(), 99),
item["display_name"],
priority[str(item["display_name"]).lower()],
str(item["display_name"]),
),
)
if not candidates:
candidates = sorted(palettes, key=lambda item: str(item["display_name"]))
# Unit palettes mark indices 204-239 as magenta placeholders; terrain and
# interface art legitimately paints there. With no explicit intent, prefer
# the candidate whose placeholders the artwork leaves unused.
if palette_kind is None and used_indices and len(candidates) > 1:
candidates = _least_placeholder_palettes(services, candidates, used_indices)
palette_asset = candidates[0]
_, palette_data = services.reader.read(palette_asset["id"])
return parse_palette(palette_data)


def _least_placeholder_palettes(
services: Services,
candidates: list[dict[str, object]],
used_indices: Collection[int],
) -> list[dict[str, object]]:
best_hits = None
for candidate in candidates:
_, palette_data = services.reader.read(str(candidate["id"]))
placeholders = parse_palette(palette_data).placeholder_indices()
hits = sum(1 for index in used_indices if index in placeholders)
if best_hits is None or hits < best_hits:
best_hits = hits
best = [candidate]
elif hits == best_hits:
best.append(candidate)
if not best_hits:
break
return best


def _alpha_composite_centered(layers: list[Image.Image]) -> Image.Image:
width = max(layer.width for layer in layers)
height = max(layer.height for layer in layers)
Expand Down
12 changes: 12 additions & 0 deletions src/ra2_explorer/codecs/pal.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

PALETTE_COLORS = 256
PALETTE_BYTES = PALETTE_COLORS * 3
# RA2 unit palettes fill indices 204-239 with this marker instead of real art,
# so art painted with those indices needs a terrain palette to render.
PLACEHOLDER_MAGENTA = (252, 0, 252)
PLAYER_COLOR_PRESETS = {
"red": (214, 59, 52),
"blue": (61, 111, 198),
Expand Down Expand Up @@ -44,6 +47,14 @@ def preview(self, *, cell_size: int = 14) -> Image.Image:
pixels[x, y] = color
return image

def placeholder_indices(self) -> frozenset[int]:
"""Indices filled with the magenta marker, which are never real art."""
return frozenset(
index
for index in range(1, PALETTE_COLORS)
if self.colors[index] == PLACEHOLDER_MAGENTA
)

def remap(
self,
color: tuple[int, int, int],
Expand Down Expand Up @@ -114,6 +125,7 @@ def grayscale_palette() -> Palette:


__all__ = [
"PLACEHOLDER_MAGENTA",
"PLAYER_COLOR_PRESETS",
"Palette",
"grayscale_palette",
Expand Down
2 changes: 2 additions & 0 deletions src/ra2_explorer/codecs/sniff.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def sniff_format(data: bytes | bytearray | memoryview, name: str | None = None)
if looks_like_shp(view):
return "shp"
sample = bytes(view[:4096])
if sample.startswith(b"BIK"):
return "video"
if sample.startswith(b"RIFF") and sample[8:12] == b"WAVE":
return "wav"
if sample.startswith(b"GABA"):
Expand Down
23 changes: 23 additions & 0 deletions src/ra2_explorer/codecs/vpl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from dataclasses import dataclass

from PIL import Image

from ra2_explorer.codecs.binary import BinaryReader, checked_product
from ra2_explorer.codecs.pal import Palette, parse_palette
from ra2_explorer.errors import InvalidFormatError
Expand All @@ -28,6 +30,27 @@ def color_index(self, section: int, color: int) -> int:
selected = max(0, min(len(self.sections) - 1, section))
return self.sections[selected][color & 0xFF]

def preview(self, *, cell_size: int = 4) -> Image.Image:
"""Render the lighting lookup as one row per section."""
if cell_size < 1:
raise ValueError("cell_size must be positive")
rows = max(1, self.section_count)
image = Image.new(
"RGB", (LOOKUP_WIDTH * cell_size, rows * cell_size)
)
pixels = image.load()
for row in range(rows):
for color in range(LOOKUP_WIDTH):
value = self.color_index(row, color)
shade = (value, value, value)
for offset_y in range(cell_size):
for offset_x in range(cell_size):
pixels[
color * cell_size + offset_x,
row * cell_size + offset_y,
] = shade
return image


def parse_vpl(data: bytes | bytearray | memoryview) -> VplFile:
reader = BinaryReader(data, format_name="VPL")
Expand Down
Loading