diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6597018..547cedf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,9 +49,11 @@ import { import { getAudioPlaybackState, pauseAudioAsset, + seekAudioAsset, subscribeAudioPlayback, toggleAudioAsset, useAudioPlayback, + useAudioProgress, } from "./audioPlayback"; import { hasLoadedCardPreview, @@ -535,7 +537,14 @@ function ruleFieldName(ruleField: string | null) { return fields[fields.length - 1] || "动画引用"; } +const mediaCategoryKinds: Record = { + voices: "voice", + sounds: "sound", + music: "music", +}; + const mediaGroupLabels: Record = { + theme_music: "主题音乐", selection_voice: "选中回应", movement_voice: "移动指令", combat_voice: "攻击指令", @@ -730,7 +739,7 @@ function staticBuildInfo(currentVersion: string) { } const previewAngleOptions: Array<{ value: PreviewAngle; label: string }> = [ { value: 0, label: "正面" }, - { value: 1, label: "右前侧(推荐)" }, + { value: 1, label: "右前侧" }, { value: 2, label: "右侧" }, { value: 3, label: "右后侧" }, { value: 4, label: "背面" }, @@ -782,7 +791,7 @@ type BrowsingLocation = { const audioFormats = ["bag_audio", "wav", "aud"]; const imageFormats = ["shp", "tmp", "pcx", "pal", "map"]; -const defaultVisibleFormats = ["bag_audio", "wav", "aud"]; +const defaultVisibleFormats = ["bag_audio", "wav", "aud", "shp", "vxl", "hva", "pal", "vpl", "video"]; function preloadEntityAudioResources(entity: GameEntity) { const primaryUrls: string[] = []; @@ -817,8 +826,13 @@ const assetCategories: Array<{ }> = [ { id: "voices", label: "游戏语音", formats: ["bag_audio", "wav", "aud"], icon: "play" }, { id: "sounds", label: "游戏音效", formats: ["bag_audio", "wav", "aud"], icon: "play" }, + { id: "music", label: "游戏音乐", formats: ["wav", "aud"], icon: "sound" }, { id: "maps", label: "地图", formats: ["map"], icon: "grid" }, { id: "images", label: "图像", formats: ["pcx"], icon: "image" }, + { id: "sprites", label: "精灵动画", formats: ["shp"], icon: "image" }, + { id: "models", label: "三维模型", formats: ["vxl", "hva"], icon: "unit" }, + { id: "palettes", label: "调色板", formats: ["pal", "vpl"], icon: "swatch" }, + { id: "videos", label: "过场影片", formats: ["video"], icon: "play" }, { id: "terrain", label: "地形素材", formats: ["tmp"], icon: "image" }, { id: "rules", label: "规则文本", formats: ["ini", "csf", "text"], icon: "file" }, ]; @@ -1049,6 +1063,17 @@ function ruleColumnSpan(label: string, value: string) { return width > 72 ? 3 : width > 34 ? 2 : 1; } +// Clamp spans against the 3-column cursor so every visual row is filled; +// otherwise a wide cell jumps to the next grid row and leaves a blank hole. +function ruleSpanLayout(rows: Array<[string, string]>, labels: Record) { + let cursor = 0; + return rows.map(([key, value]) => { + const span = Math.min(ruleColumnSpan(labels[key], value), 3 - cursor); + cursor = (cursor + span) % 3; + return span; + }); +} + const appliedRememberedScrollResets = new Map(); function useRememberedScroll< @@ -1444,8 +1469,8 @@ function ExplorerApp() { || visibleCategories[0] || null; const selectedCategoryId = selectedCategory?.id || ""; - const isMediaCategory = selectedCategoryId === "voices" || selectedCategoryId === "sounds"; - const mediaKind: MediaKind = selectedCategoryId === "voices" ? "voice" : "sound"; + const isMediaCategory = selectedCategoryId in mediaCategoryKinds; + const mediaKind: MediaKind = mediaCategoryKinds[selectedCategoryId] || "sound"; const categoryFormats = (selectedCategory?.formats || []) .filter((formatName) => enabledFormats.includes(formatName)); const assetFormats = assetFormatTag && categoryFormats.includes(assetFormatTag) @@ -2697,16 +2722,16 @@ function ExplorerApp() { })} - {visibleCategories.some((item) => ["voices", "sounds"].includes(item.id)) &&
+ {visibleCategories.some((item) => item.id in mediaCategoryKinds) &&
声音{mediaKindCounts.reduce((count, item) => count + item.count, 0) || categoryCount(stats, audioFormats)}
- {visibleCategories.filter((item) => ["voices", "sounds"].includes(item.id)).map((item) =>
- - {view === "assets" && assetCategory === item.id && mediaGroups.filter((group) => group.group.endsWith(item.id === "voices" ? "_voice" : "_sound")).map((group) => )} -
)} + {visibleCategories.filter((item) => item.id in mediaCategoryKinds).map((item) => { const kind = mediaCategoryKinds[item.id]; return
+ + {view === "assets" && assetCategory === item.id && mediaGroups.filter((group) => group.group.endsWith(`_${kind}`)).map((group) => )} +
; })}
} - {visibleCategories.filter((item) => !["voices", "sounds"].includes(item.id)).map((item) => ( + {visibleCategories.filter((item) => !(item.id in mediaCategoryKinds)).map((item) => ( @@ -2936,9 +2961,10 @@ function AssetGridCard({ asset, selected, onSelect }: { asset: Asset; selected: return ( @@ -4499,6 +4525,30 @@ function CompactAudioPlayer({ assetId, label }: { ; } +// Playhead and elapsed/total readout for the shared audio element. The bar +// stays laid out even before playback so the row never jumps, but it only +// becomes seekable once this asset owns playback and its duration is known. +function AudioProgress({ assetId, durationSeconds }: { + assetId: string; + durationSeconds?: number; +}) { + const progress = useAudioProgress(); + const owned = progress.assetId === assetId && progress.duration > 0; + const currentTime = owned ? progress.currentTime : 0; + const duration = owned ? progress.duration : durationSeconds || 0; + const played = duration > 0 ? (currentTime / duration) * 100 : 0; + return <> + seekAudioAsset(Number(event.target.value))} + style={{ "--audio-progress": `${played}%` } as CSSProperties} + title={owned ? "拖动以跳转" : "播放后可跳转"} + aria-label="播放进度" aria-valuetext={formatDuration(currentTime)} /> + {formatDuration(currentTime)} / {duration > 0 ? formatDuration(duration) : "--:--"} + ; +} + function AudioDownloadAction({ assetId, label }: { assetId: string; label: string }) { return ; } @@ -4955,11 +5005,12 @@ function EntitySoundSample({ sample, voiceTextPreference }: { sample: MediaSampl ); const localizedText = preferredText?.value !== originalText ? preferredText : null; const internalName = audioDisplayName(sample.name); - return
- {internalName}{sample.weight > 1 && ×{sample.weight}} + const missing = !sample.asset || !audioFormats.includes(sample.asset.format); + return
+ {internalName}{sample.weight > 1 && ×{sample.weight}}{missing && 未解析} {sample.asset && audioFormats.includes(sample.asset.format) ? - : 未解析} + : null} {originalText && 原文{originalText}} {localizedText && {localizedText.label}{localizedText.value}} @@ -5437,6 +5488,7 @@ function EntityDetailPanel({ sourceId, sourceRevision = "", entity, loading, pla if (loading && !entity) return ; if (!entity) return ; const rules = Object.entries(entity.rules).filter(([key]) => ruleLabels[key]); + const ruleSpans = ruleSpanLayout(rules, ruleLabels); const soundAssociations = mergeSoundAssociations(entity.media); const soundCount = soundAssociations.reduce((count, association) => count + association.samples.length, 0); const hasBodySequences = bodyAnimationAssociations.length > 0; @@ -5666,7 +5718,7 @@ function EntityDetailPanel({ sourceId, sourceRevision = "", entity, loading, pla {rules.length > 0 &&
规则属性{rules.length} -
{rules.map(([key, value]) =>
{ruleLabels[key]}
{value}
)}
+
{rules.map(([key, value], index) =>
{ruleLabels[key]}
{value}
)}
} {dependencyGroups.length > 0 &&
@@ -5874,16 +5926,16 @@ function DetailPanel({ asset, metadata, textAsset, textQuery, setTextQuery, fram
{formatLabels[asset.format] || asset.format.toUpperCase()}

{detailHeading}

{onPopout && } - {isAudio - ?
- - {metadata?.duration_seconds !== undefined ? formatDuration(metadata.duration_seconds) : ""} - -
- : } + {!isAudio && }
+ {isAudio &&
+ + + +
} + {hasAudioRelationshipTabs &&
@@ -5916,8 +5968,11 @@ function DetailPanel({ asset, metadata, textAsset, textQuery, setTextQuery, fram {asset.format === "video" &&
{videoRequested && !videoFailed - ? - : } + ? + : <> + { event.currentTarget.hidden = true; }} /> + + } {videoFailed && 视频转换失败}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1cfa878..11ade81 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { @@ -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, diff --git a/frontend/src/audioPlayback.ts b/frontend/src/audioPlayback.ts index 19ceec8..7d10d54 100644 --- a/frontend/src/audioPlayback.ts +++ b/frontend/src/audioPlayback.ts @@ -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 = ""; @@ -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(); @@ -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; } @@ -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); @@ -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"; } @@ -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)) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 83772e3..a7ac661 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,6 +1,6 @@ :root { color-scheme: dark; - font-family: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + font-family: "Segoe UI", -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; color: #e8e9eb; background: #101114; font-synthesis: none; @@ -20,7 +20,8 @@ --entity-accent: #74a9d4; --voice-accent: #c18bd6; --sound-accent: #d6ad5d; - --sans-condensed: "Bahnschrift SemiCondensed", "Arial Narrow", "Microsoft YaHei UI", sans-serif; + --sans-condensed: "Bahnschrift SemiCondensed", "Avenir Next Condensed", "Arial Narrow", "PingFang SC", "Microsoft YaHei UI", sans-serif; + --mono: "Cascadia Mono", Consolas, "SF Mono", Menlo, Monaco, monospace; } * { box-sizing: border-box; } @@ -45,7 +46,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .button.ghost { background: transparent; } .button.compact { min-height: 34px; padding: 0 12px; font-size: 12px; } .button.large { min-height: 48px; padding: 0 21px; font-size: 14px; } -.button:disabled { opacity: .45; cursor: wait; } +.button:disabled { opacity: .45; cursor: not-allowed; } .workspace { --sidebar-width: 224px; --detail-panel-size: 430px; height: 100vh; min-height: 0; display: grid; } .workspace.detail-right { grid-template-columns: var(--sidebar-width) minmax(360px, 1fr) 6px minmax(320px, var(--detail-panel-size)); } @@ -84,9 +85,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .sidebar-settings:hover { border-color: #555a64; color: #f0f1f2; background: #1d2025; } .sidebar-settings .icon { color: #d05a54; } .sidebar-settings > i { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #e1534d; box-shadow: 0 0 8px rgba(225,83,77,.7); } -.source-heading { padding: 24px 17px 17px; border-bottom: 1px solid var(--line-soft); } +.source-heading { padding: 17px; border-bottom: 1px solid var(--line-soft); } .eyebrow { display: block; color: #a7abb3; font: 600 10px/1 var(--sans-condensed); letter-spacing: .18em; text-transform: uppercase; } -.source-select-wrap { position: relative; display: block; margin-top: 10px; } +.source-select-wrap { position: relative; display: block; } .source-select-wrap select { width: 100%; padding: 0 24px 0 26px; border: 0; color: #f0f0f1; background: transparent; font-size: 15px; font-weight: 600; appearance: none; cursor: pointer; } .source-select-wrap .icon { position: absolute; right: 0; top: 3px; color: var(--quiet); transform: rotate(90deg); pointer-events: none; } .source-select-wrap > .icon:first-child { right: auto; left: 0; transform: none; } @@ -105,7 +106,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .library-tree button { position: relative; width: 100%; height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 9px; border: 0; border-left: 2px solid transparent; border-radius: 2px; color: var(--muted); background: transparent; cursor: pointer; } .tree-children button::before { content: ""; position: absolute; top: 50%; left: -12px; width: 10px; height: 1px; background: #343740; } .tree-child-group { min-width: 0; } -.tree-grandchild { height: 33px !important; padding-left: 20px !important; color: #777d87 !important; } +.library-tree .tree-grandchild { height: 33px; padding-left: 20px; color: #777d87; } .tree-grandchild::after { content: ""; position: absolute; top: 50%; left: 9px; width: 4px; height: 4px; border-radius: 50%; background: #555a64; transform: translateY(-50%); } .library-tree button:hover { color: #d9dadd; background: rgba(255,255,255,.025); } .library-tree button.active { border-left-color: var(--red-bright); color: #f1f1f2; background: linear-gradient(90deg, rgba(212,59,52,.14), rgba(212,59,52,.02)); } @@ -118,7 +119,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .workspace.sidebar-collapsed .sidebar-toggle { width: 40px; } .workspace.sidebar-collapsed .sidebar-toggle .icon { transform: none; } .workspace.sidebar-collapsed .source-heading { height: 48px; padding: 7px; } -.workspace.sidebar-collapsed .source-select-wrap { height: 34px; margin: 0; } +.workspace.sidebar-collapsed .source-select-wrap { height: 34px; } .workspace.sidebar-collapsed .source-select-wrap select { position: absolute; inset: 0; z-index: 2; padding: 0; opacity: 0; } .workspace.sidebar-collapsed .source-select-wrap > .icon:first-child { top: 8px; left: 13px; } .workspace.sidebar-collapsed .source-select-wrap > .icon:last-child { display: none; } @@ -148,7 +149,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .asset-panel { min-height: 0; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); background: #14161a; } .entity-panel, .media-panel { container-type: inline-size; } -.asset-toolbar { height: 66px; display: flex; align-items: center; gap: 15px; padding: 0 18px; border-bottom: 1px solid var(--line-soft); } +.asset-toolbar { height: 64px; display: flex; align-items: center; gap: 15px; padding: 0 18px; border-bottom: 1px solid var(--line-soft); } .search-box { height: 36px; flex: 1; display: flex; align-items: center; gap: 9px; padding: 0 10px; border: 1px solid var(--line); border-radius: 3px; color: var(--quiet); background: #101216; transition: border-color .15s; } .search-box:focus-within { border-color: #7f3b38; } .search-box input { min-width: 0; flex: 1; border: 0; outline: 0; color: #e6e7e9; background: none; font-size: 12px; } @@ -160,7 +161,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .entity-search-input .search-box { width: 100%; } .entity-search-options { flex: 0 0 auto; display: flex; align-items: center; gap: 5px; } .entity-search-suggestions, .catalog-search-history { position: absolute; z-index: 35; top: calc(100% + 6px); left: 0; right: 0; max-height: min(430px, 58vh); padding: 5px; overflow-y: auto; border: 1px solid #42464f; border-radius: 4px; background: #17191e; box-shadow: 0 14px 34px rgba(0,0,0,.48); scrollbar-gutter: stable; } -.entity-search-suggestions > button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 42px minmax(100px, .7fr) minmax(130px, 1fr); align-items: center; gap: 9px; padding: 5px 9px 5px 6px; border: 1px solid transparent; border-left-width: 2px; border-radius: 3px; color: #b7bbc2; background: transparent; text-align: left; cursor: pointer; } +.entity-search-suggestions > button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 42px minmax(100px, 1.2fr) minmax(130px, 1fr); align-items: center; gap: 9px; padding: 5px 9px 5px 6px; border: 1px solid transparent; border-left-width: 2px; border-radius: 3px; color: #b7bbc2; background: transparent; text-align: left; cursor: pointer; } .entity-search-suggestions > button.catalog-suggestion-entity { border-left-color: var(--entity-accent); } .entity-search-suggestions > button.catalog-suggestion-voice { border-left-color: var(--voice-accent); } .entity-search-suggestions > button.catalog-suggestion-sound { border-left-color: var(--sound-accent); } @@ -189,7 +190,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .catalog-history-entity { border-left-color: var(--entity-accent); } .catalog-history-voice { border-left-color: var(--voice-accent); } .catalog-history-sound { border-left-color: var(--sound-accent); } -.catalog-history-target { min-width: 0; min-height: 48px; display: grid; grid-template-columns: 42px minmax(100px, .7fr) minmax(120px, 1fr); align-items: center; gap: 9px; padding: 5px 7px 5px 5px; border: 0; color: #b7bbc2; background: transparent; text-align: left; cursor: pointer; } +.catalog-history-target { min-width: 0; min-height: 48px; display: grid; grid-template-columns: 42px minmax(100px, 1.2fr) minmax(120px, 1fr); align-items: center; gap: 9px; padding: 5px 7px 5px 5px; border: 0; color: #b7bbc2; background: transparent; text-align: left; cursor: pointer; } .catalog-history-target > em { min-width: 0; overflow: hidden; color: #8f789b; font-size: 12px; font-style: normal; text-align: right; text-overflow: ellipsis; white-space: nowrap; } .catalog-history-remove { width: 28px; height: 28px; align-self: center; display: grid; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 3px; color: #656b75; background: transparent; cursor: pointer; } .catalog-history-remove:hover { border-color: #6b3a38; color: #e06a64; background: #281b1d; } @@ -293,7 +294,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .asset-size { color: #777c85; font: 500 10px/1 var(--sans-condensed); text-align: right; } .asset-row > .icon { color: #4d5159; } .entity-row { grid-template-columns: 34px minmax(135px, 1fr) 70px 80px 12px; } -.media-row { grid-template-columns: 34px minmax(160px, 1fr) minmax(110px, .45fr) 12px; } +.media-row { grid-template-columns: 34px minmax(160px, 1.2fr) minmax(110px, 1fr) 12px; } .media-row.playing, .media-card.playing { background: linear-gradient(90deg, rgba(126,71,151,.16), rgba(255,255,255,.018)); } .media-links { min-width: 0; overflow: hidden; color: #888e98; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .file-icon.format-audio { border-color: #4d3e58; color: #b58acb; background: #26212b; } @@ -333,7 +334,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .no-results span { margin-top: 6px; font-size: 11px; } .no-results button { margin-top: 13px; border: 0; color: #c86d67; background: none; font-size: 11px; cursor: pointer; } -.detail-panel { min-height: 0; padding: 12px 14px 14px; overflow-y: scroll; scrollbar-color: #555b66 #17191d; scrollbar-width: auto; scrollbar-gutter: stable; } +.detail-panel { min-height: 0; padding: 12px 14px 14px; overflow-y: scroll; scrollbar-color: #555b66 #17191d; scrollbar-width: auto; scrollbar-gutter: stable; container-type: inline-size; } .detail-title { min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .detail-title > div { min-width: 0; } .detail-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 7px; } @@ -341,9 +342,20 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .detail-title .layout-toggle button { width: 28px; height: 28px; } .detail-heading-line { min-width: 0; display: flex; align-items: center; gap: 9px; overflow: hidden; } .audio-detail-title .detail-heading-line { flex: 1 1 auto; } -.audio-header-player { width: 112px; min-width: 112px; height: 32px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; gap: 7px; } -.audio-header-meta { min-width: 0; overflow: hidden; color: #898e97; font: 12px/1 var(--sans-condensed); text-align: center; text-overflow: ellipsis; white-space: nowrap; } -.audio-header-player .audio-download-action { justify-self: end; } +.audio-detail-player { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin-top: 10px; padding: 7px 9px; border: 1px solid var(--line-soft); border-radius: 3px; background: #14161a; } +.audio-detail-player > .compact-audio-player, .audio-detail-player > .audio-download-action { flex: 0 0 auto; } +/* Mirrors .frame-controls input: a slim native slider, here filled up to the playhead. */ +.audio-progress { flex: 1 1 160px; min-width: 120px; height: 12px; margin: 0; appearance: none; background: transparent; cursor: pointer; } +.audio-progress:disabled { cursor: default; } +.audio-progress-time { flex: 0 0 auto; color: #9a9ea6; font: 500 12px/1 var(--sans-condensed); white-space: nowrap; } +.audio-progress-time i { margin: 0 2px; color: #4f535b; font-style: normal; } +.audio-progress::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: linear-gradient(to right, var(--voice-accent) var(--audio-progress, 0%), var(--line) var(--audio-progress, 0%)); } +.audio-progress::-webkit-slider-thumb { width: 9px; height: 9px; margin-top: -3px; border: 0; border-radius: 50%; background: var(--voice-accent); appearance: none; } +.audio-progress::-moz-range-track { height: 3px; border-radius: 2px; background: var(--line); } +.audio-progress::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--voice-accent); } +.audio-progress::-moz-range-thumb { width: 9px; height: 9px; border: 0; border-radius: 50%; background: var(--voice-accent); } +.audio-progress:hover::-webkit-slider-thumb { background: #e3c7ef; } +.audio-progress:hover::-moz-range-thumb { background: #e3c7ef; } .format-pill { display: inline-block; padding: 3px 6px; border: 1px solid #61312f; border-radius: 2px; color: #d77772; background: rgba(187,52,47,.08); font: 600 8px/1 var(--sans-condensed); letter-spacing: .11em; text-transform: uppercase; } .detail-title h2 { min-width: 0; margin: 0; overflow: hidden; color: #f0f0f1; font: 600 18px/1.15 var(--sans-condensed); letter-spacing: .015em; text-overflow: ellipsis; white-space: nowrap; } .detail-title > div > small { flex: 0 1 auto; overflow: hidden; color: #747983; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -395,7 +407,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .text-preview { margin-top: 19px; border: 1px solid var(--line); background: #101216; } .text-preview label { height: 34px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border-bottom: 1px solid var(--line); color: var(--quiet); } .text-preview input { min-width: 0; flex: 1; border: 0; outline: 0; color: #d7d9dc; background: transparent; font-size: 10px; } -.text-preview pre { height: 250px; margin: 0; padding: 11px; overflow: scroll; color: #aeb4aa; font: 12px/1.55 "Cascadia Mono", Consolas, monospace; white-space: pre-wrap; word-break: break-word; scrollbar-color: #555b66 #17191d; scrollbar-gutter: stable; } +.text-preview pre { height: 250px; margin: 0; padding: 11px; overflow: scroll; color: #aeb4aa; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; scrollbar-color: #555b66 #17191d; scrollbar-gutter: stable; } .text-preview > small { display: block; padding: 7px 10px; border-top: 1px solid var(--line-soft); color: #666b74; font-size: 8px; } .metadata { margin-top: 20px; } .metadata h3 { margin: 0 0 8px; color: #aeb1b7; font: 600 10px/1 var(--sans-condensed); letter-spacing: .14em; text-transform: uppercase; } @@ -441,7 +453,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .sound-transcript-tag dd { white-space: normal; overflow-wrap: anywhere; } } .detail-panel-wide .preview-block, .detail-panel-wide .video-preview, .detail-panel-wide .text-preview { max-width: 860px; margin-right: auto; margin-left: auto; } -.mono { font-family: "Cascadia Mono", Consolas, monospace; letter-spacing: .05em; } +.mono { font-family: var(--mono); letter-spacing: .05em; } .export-button { width: 100%; margin-top: 13px; background: #1e2025; } .unsupported-preview { height: 190px; margin-top: 19px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px dashed #363940; color: #646972; background: #14161a; } .unsupported-preview strong { margin-top: 12px; color: #aeb1b7; font-size: 12px; } @@ -498,7 +510,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .entity-preview-controls { position: absolute; z-index: 5; left: 50%; bottom: 10px; width: min(calc(100% - 20px), 620px); display: flex; align-items: stretch; justify-content: center; gap: 6px; transform: translateX(-50%); } .entity-preview-controls > .frame-controls { min-width: 0; flex: 1 1 auto; border-top: 1px solid var(--line); border-radius: 3px; background: rgba(22,24,28,.91); box-shadow: 0 5px 18px rgba(0,0,0,.28); backdrop-filter: blur(5px); } .animation-candidate-control { min-width: 0; min-height: 43px; flex: 0 1 190px; display: grid; grid-template-columns: auto minmax(72px, 1fr); align-items: center; gap: 7px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 3px; color: #858a94; background: rgba(22,24,28,.94); box-shadow: 0 5px 18px rgba(0,0,0,.28); font-size: 12px; backdrop-filter: blur(5px); } -.animation-candidate-control select { min-width: 0; width: 100%; height: 29px; padding: 0 6px; overflow: hidden; border: 1px solid #3b3f47; border-radius: 2px; outline: 0; color: #d3d5d9; background: #111317; font: 12px/1 "Cascadia Mono", Consolas, monospace; text-overflow: ellipsis; } +.animation-candidate-control select { min-width: 0; width: 100%; height: 29px; padding: 0 6px; overflow: hidden; border: 1px solid #3b3f47; border-radius: 2px; outline: 0; color: #d3d5d9; background: #111317; font: 12px/1 var(--mono); text-overflow: ellipsis; } .entity-detail-narrow .animation-candidate-control { flex-basis: 108px; grid-template-columns: minmax(0, 1fr); } .entity-detail-narrow .animation-candidate-control > span { display: none; } .entity-effect-overlay { position: absolute; z-index: 3; inset: 0; overflow: hidden; pointer-events: none; transition: opacity 40ms linear; } @@ -600,7 +612,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .format-checks .file-icon { width: 27px; height: 27px; } .format-checks strong { min-width: 0; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .format-checks em { color: #7c818a; font-size: 11px; font-style: normal; } -.settings-empty { min-height: 52px; display: grid; place-items: center; padding: 12px; border: 1px dashed #343840; color: #777d86; background: #15171a; font-size: 13px; text-align: center; } +.settings-empty { margin-top: 10px; min-height: 52px; display: grid; place-items: center; padding: 12px; border: 1px dashed #343840; color: #777d86; background: #15171a; font-size: 13px; text-align: center; } .settings-source-list, .resource-pack-list { display: grid; gap: 7px; } .settings-source-list article, .resource-pack-list article { min-width: 0; min-height: 52px; display: grid; grid-template-columns: 31px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid var(--line-soft); background: #15171b; } .settings-source-list article.active { border-color: #513735; background: #1d191c; } @@ -609,7 +621,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .settings-source-list strong, .resource-pack-list strong { color: #d5d7db; font-size: 13px; } .settings-source-list small, .resource-pack-list small { margin-top: 4px; color: #777d86; font-size: 12px; } .settings-source-list article > button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line); border-radius: 3px; color: #aaaeb5; background: #1c1e23; font-size: 12px; cursor: pointer; } -.settings-source-list article > button:disabled { opacity: .55; cursor: wait; } +.settings-source-list article > button:disabled { opacity: .55; cursor: not-allowed; } .settings-discoveries { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 12px; } .settings-discoveries button { min-width: 0; min-height: 52px; display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 7px 9px; border: 1px solid #344039; color: #82a98d; background: #151a17; text-align: left; cursor: pointer; } .settings-discoveries button > span { min-width: 0; } @@ -670,6 +682,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .asset-card-preview { position: relative; width: 100%; height: 120px; flex: 0 0 auto; display: grid; place-items: center; overflow: hidden; color: #737983; background-color: #111317; background-image: linear-gradient(45deg,#181a1f 25%,transparent 25%),linear-gradient(-45deg,#181a1f 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#181a1f 75%),linear-gradient(-45deg,transparent 75%,#181a1f 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; } .asset-card-preview > img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; padding: 10px; image-rendering: pixelated; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0,0,0,.52)); } .asset-card-preview.format-bag_audio, .asset-card-preview.format-wav, .asset-card-preview.format-aud { color: #b78acb; background: linear-gradient(145deg,#211b27,#14161a); } +/* Movie frames are photographic, not pixel art. */ +.asset-card-preview.format-video > img { image-rendering: auto; } .asset-card-copy { min-width: 0; padding: 10px 11px 11px; } .asset-card-copy strong, .asset-card-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .asset-card-copy strong { color: #dddfe2; font-size: 13px; font-weight: 600; } @@ -681,7 +695,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .load-more { width: calc(100% - 24px); min-height: 38px; margin: 12px; border: 1px solid var(--line); border-radius: 3px; color: #aeb2b9; background: #1b1d22; font-size: 12px; cursor: pointer; } .asset-grid .load-more { width: 100%; margin: 0; } .load-more:hover:not(:disabled) { border-color: #555a64; color: #eef0f2; } -.load-more:disabled { opacity: .6; cursor: wait; } +.load-more:disabled { opacity: .6; cursor: not-allowed; } .entity-card-preview { height: 135px; } .entity-card-preview.catalog-suggestion-thumbnail { width: 38px; height: 38px; border: 1px solid #47779b; border-radius: 4px; background-size: 8px 8px; } .entity-card-preview.catalog-suggestion-thumbnail > img { padding: 3px; filter: drop-shadow(0 2px 3px rgba(0,0,0,.5)); } @@ -704,8 +718,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .voxel-canvas, .voxel-canvas canvas { width: 100%; height: 100%; display: block; } .voxel-canvas canvas { cursor: grab; } .voxel-canvas canvas:active { cursor: grabbing; } -.voxel-reset { position: absolute; z-index: 2; top: 10px; right: 10px; min-height: 29px; display: inline-flex; align-items: center; padding: 0 9px; border: 1px solid rgba(91,96,107,.75); border-radius: 3px; color: #c4c7cd; background: rgba(15,17,21,.82); font-size: 11px; backdrop-filter: blur(4px); } -.voxel-reset { cursor: pointer; } +.voxel-reset { position: absolute; z-index: 2; top: 10px; right: 10px; min-height: 29px; display: inline-flex; align-items: center; padding: 0 9px; border: 1px solid rgba(91,96,107,.75); border-radius: 3px; color: #c4c7cd; background: rgba(15,17,21,.82); font-size: 11px; cursor: pointer; backdrop-filter: blur(4px); } .voxel-reset:hover { border-color: #8d4a46; color: #f0f1f2; } .voxel-status { position: absolute; inset: 0; display: grid; place-items: center; color: #a8acb4; background: rgba(15,17,21,.72); font-size: 12px; } .voxel-status.error { color: #df7a74; } @@ -713,7 +726,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .entity-tags { display: flex; flex-wrap: wrap; gap: 7px; padding: 0 9px 10px; } .entity-tags > span { min-height: 28px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 1px solid #383b43; border-radius: 14px; color: #aeb2ba; background: #16181d; font-size: 12px; } -.entity-tags code { color: #dc7771; font: 600 11px/1 "Cascadia Mono", Consolas, monospace; } +.entity-tags code { color: #dc7771; font: 600 11px/1 var(--mono); } .entity-section { margin-top: 12px; border: 1px solid var(--line-soft); background: #14161a; } .entity-section > summary, .dependency-groups > details > summary { min-height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 11px; color: #c8cbd0; cursor: pointer; list-style: none; } .entity-section > summary::-webkit-details-marker, .dependency-groups summary::-webkit-details-marker { display: none; } @@ -727,17 +740,22 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .media-association-list header strong { flex: 0 0 auto; color: #b38ac5; font-size: 12px; font-weight: 600; } .media-association-slots { min-width: 0; display: flex; align-items: center; gap: 4px; overflow: hidden; } .media-association-slots strong { padding: 3px 6px; border: 1px solid #403746; border-radius: 10px; background: #211c25; white-space: nowrap; } -.media-association-list header code { overflow: hidden; color: #cfd1d5; font: 600 12px/1 "Cascadia Mono", Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } +.media-association-list header code { overflow: hidden; color: #cfd1d5; font: 600 12px/1 var(--mono); text-overflow: ellipsis; white-space: nowrap; } .media-association-list header em { margin-left: auto; color: #727781; font-size: 11px; font-style: normal; } -.media-sample { min-width: 0; min-height: 40px; display: grid; grid-template-columns: minmax(90px, .55fr) 30px minmax(0, 2.4fr) 30px; align-items: center; gap: 10px; padding: 5px 8px; } +.media-sample { min-width: 0; min-height: 40px; display: grid; grid-template-columns: minmax(90px, 1fr) auto minmax(160px, 2fr) 30px; align-items: center; gap: 10px; padding: 5px 8px; } +.media-sample-no-audio { grid-template-columns: minmax(90px, 1fr) minmax(0, 2fr); } .media-sample + .media-sample { border-top: 1px solid var(--line-soft); } -.media-sample-id { min-width: 0; overflow: hidden; color: #d3d5d9; font: 600 12px/1 "Cascadia Mono", Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } +.media-sample-id { min-width: 0; overflow: hidden; color: #d3d5d9; font: 600 12px/1 var(--mono); text-overflow: ellipsis; white-space: nowrap; } .media-sample-id em { margin-left: 5px; color: #8f7598; font: 500 11px/1 var(--sans-condensed); font-style: normal; } .media-sample-texts { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; } .media-sample-texts.bilingual { grid-template-columns: minmax(0, 1fr); gap: 3px; } .media-sample-copy { min-width: 0; overflow: hidden; color: #c7c9cd; font-size: 12px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .media-sample-copy b { margin-right: 6px; color: #737985; font-size: 11px; font-weight: 500; } -.media-sample-missing { color: #8f755a; font-size: 11px; font-style: normal; } +.media-sample-id .media-sample-missing { color: #8f755a; } + +@container (max-width: 640px) { + .media-sample-texts.bilingual { grid-template-columns: minmax(0, 1fr); } +} .compact-audio-player { width: 30px; height: 30px; display: inline-grid; place-items: center; } .compact-audio-player audio { display: none; } .compact-audio-player button, .audio-download-action { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border: 1px solid #3d3a43; border-radius: 3px; color: #b68ac9; background: #211d25; text-decoration: none; cursor: pointer; } @@ -761,13 +779,13 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .animation-thumbnail.body-action { color: #84a98c; background: #18201b; } .dependency-groups { display: grid; gap: 7px; } .dependency-groups > details { border: 1px solid var(--line-soft); background: #14171a; } -.dependency-groups summary strong { min-width: 0; overflow: hidden; color: #d5d7da; font: 600 12px/1 "Cascadia Mono", Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } +.dependency-groups summary strong { min-width: 0; overflow: hidden; color: #d5d7da; font: 600 12px/1 var(--mono); text-overflow: ellipsis; white-space: nowrap; } .dependency-compact { display: grid; gap: 6px; padding: 0 8px 8px; } .dependency-compact article { padding: 8px; border: 1px solid #2b2e34; background: #181a1e; } .dependency-compact article.unresolved { border-color: #6e5130; } .dependency-compact header { display: flex; align-items: center; gap: 8px; } .dependency-compact header span { color: #858b94; font-size: 12px; } -.dependency-compact header code { color: #d2d5d9; font: 600 12px/1 "Cascadia Mono", Consolas, monospace; } +.dependency-compact header code { color: #d2d5d9; font: 600 12px/1 var(--mono); } .dependency-compact header em { margin-left: auto; color: var(--amber); font-size: 11px; font-style: normal; } .property-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } .property-tags span { max-width: 100%; display: inline-flex; gap: 5px; padding: 5px 7px; overflow: hidden; border: 1px solid #30343a; border-radius: 12px; color: #aeb2b9; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } @@ -818,12 +836,12 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .detached-shell > .entity-detail-wide { overflow-y: hidden; } .detached-error { padding: 28px; color: #df7a74; font-size: 14px; } .asset-associations { margin-top: 18px; } -.asset-associations h3 { margin: 0 0 8px; color: #aeb1b7; font: 600 11px/1 var(--sans-condensed); letter-spacing: .12em; text-transform: uppercase; } +.asset-associations h3 { margin: 0 0 8px; color: #aeb1b7; font: 600 11px/1 var(--sans-condensed); letter-spacing: .14em; text-transform: uppercase; } .asset-associations > div { display: flex; flex-wrap: wrap; gap: 7px; } .asset-associations article { min-width: 0; max-width: 100%; padding: 8px 10px; border: 1px solid #39343e; border-radius: 3px; background: #19171d; } .asset-associations article span { display: block; color: #ad83c0; font-size: 11px; } .asset-associations article strong, .asset-associations article code { display: block; margin-top: 4px; overflow: hidden; color: #d4d6da; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.asset-associations article code { color: #8f949d; font-family: "Cascadia Mono", Consolas, monospace; } +.asset-associations article code { color: #8f949d; font-family: var(--mono); } .asset-associations article p { margin: 6px 0 0; color: #e2e3e5; font-size: 13px; line-height: 1.45; } .asset-associations article p b { margin-right: 7px; color: #8e749b; font-size: 11px; font-weight: 500; } .detail-panel-wide .asset-associations:not(.audio-associations) > div { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); } @@ -836,8 +854,13 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .audio-association-texts > div { width: 100%; min-width: 0; padding: 5px 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: start; gap: 10px; } .audio-association-texts dt { color: #94749f; font-size: 12px; line-height: 1.5; } .audio-association-texts dd { min-width: 0; margin: 0; color: #d8d9dc; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } -.video-preview { min-height: 230px; margin-top: 19px; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--line); background: #0d0f12; } +.video-preview { position: relative; min-height: 230px; margin-top: 19px; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--line); background: #0d0f12; } .video-preview video { width: 100%; max-height: 360px; display: block; } +.video-poster { width: 100%; max-height: 360px; display: block; object-fit: contain; } +/* Floating overlay control, following .voxel-reset; kept free of .button so + its centering transform is never overridden by .button:active. */ +.video-convert { position: absolute; z-index: 1; left: 50%; top: 50%; width: 54px; height: 54px; display: grid; place-items: center; padding: 0; border: 1px solid rgba(140,145,155,.55); border-radius: 50%; color: #e9ebee; background: rgba(13,15,18,.66); cursor: pointer; backdrop-filter: blur(3px); transform: translate(-50%, -50%); transition: border-color .15s, background .15s, color .15s; } +.video-convert:hover { border-color: #8d4a46; color: #f0f1f2; background: rgba(15,17,21,.88); } .video-error { color: #d97570; font-size: 13px; } .asset-list::-webkit-scrollbar, .detail-panel::-webkit-scrollbar, .entity-detail-sections::-webkit-scrollbar, .library-tree::-webkit-scrollbar, .tag-filter::-webkit-scrollbar, .text-preview pre::-webkit-scrollbar, .search-results-scroll::-webkit-scrollbar, .entity-search-suggestions::-webkit-scrollbar, .catalog-search-history::-webkit-scrollbar { width: 11px; height: 7px; } @@ -921,11 +944,11 @@ body { font-size: 14px; } .source-panel { display: block; border-right: 0; } .sidebar-brand { min-height: 58px; } .sidebar-settings { margin-bottom: 9px; } - .source-heading { padding: 14px 13px 11px; } + .source-heading { padding: 11px 13px; } .summary-grid, .reference-card { display: none; } .library-tree { max-height: 310px; } .asset-panel { height: 480px; } - .asset-toolbar { height: 57px; padding: 0 12px; } + .asset-toolbar { height: 58px; padding: 0 12px; } .entity-filter { width: 86px; } .result-count { display: none; } .asset-row { padding-left: 12px; } @@ -935,8 +958,8 @@ body { font-size: 14px; } .preview-stage { height: 300px; } .media-group-items.list-columns { grid-template-columns: 1fr; } .media-group-items.list-columns > .asset-row:nth-child(odd) { border-right: 0; } - .media-sample { grid-template-columns: minmax(90px, 1fr) 30px 30px; } - .media-sample .compact-audio-player, .media-sample .media-sample-missing { grid-column: 2; } + .media-sample { grid-template-columns: minmax(90px, 1fr) auto 30px; } + .media-sample .compact-audio-player { grid-column: 2; } .media-sample .audio-download-action { grid-column: 3; } .media-sample-texts { grid-column: 1 / -1; grid-row: 2; } .media-sample-texts.bilingual { grid-template-columns: minmax(0, 1fr); } diff --git a/src/ra2_explorer/api.py b/src/ra2_explorer/api.py index 0705632..da7c322 100644 --- a/src/ra2_explorer/api.py +++ b/src/ra2_explorer/api.py @@ -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 @@ -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, @@ -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) @@ -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="未知音频排序方式") @@ -770,6 +778,7 @@ def asset_preview( "tmp", "pcx", "map", + "vpl", }: raise HTTPException(status_code=409, detail="该格式没有图像预览") artifact_path = _asset_artifact_path( @@ -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 ( @@ -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) @@ -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) @@ -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: @@ -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) diff --git a/src/ra2_explorer/codecs/pal.py b/src/ra2_explorer/codecs/pal.py index c90b7d7..c48faf4 100644 --- a/src/ra2_explorer/codecs/pal.py +++ b/src/ra2_explorer/codecs/pal.py @@ -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), @@ -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], @@ -114,6 +125,7 @@ def grayscale_palette() -> Palette: __all__ = [ + "PLACEHOLDER_MAGENTA", "PLAYER_COLOR_PRESETS", "Palette", "grayscale_palette", diff --git a/src/ra2_explorer/codecs/sniff.py b/src/ra2_explorer/codecs/sniff.py index 579c407..676afda 100644 --- a/src/ra2_explorer/codecs/sniff.py +++ b/src/ra2_explorer/codecs/sniff.py @@ -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"): diff --git a/src/ra2_explorer/codecs/vpl.py b/src/ra2_explorer/codecs/vpl.py index 05bbc90..b262837 100644 --- a/src/ra2_explorer/codecs/vpl.py +++ b/src/ra2_explorer/codecs/vpl.py @@ -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 @@ -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") diff --git a/src/ra2_explorer/library.py b/src/ra2_explorer/library.py index dec3e82..a7f4ff0 100644 --- a/src/ra2_explorer/library.py +++ b/src/ra2_explorer/library.py @@ -10,6 +10,7 @@ from ra2_explorer.codecs.bag import parse_bag_index from ra2_explorer.codecs.mix import ( MixHashType, + MixIndex, classic_mix_hash, parse_local_mix_database, parse_mix, @@ -35,6 +36,24 @@ MAX_NESTING_DEPTH = 6 MAX_ARCHIVES_PER_SOURCE = 4096 MAX_SNIFF_BYTES = 67_108_864 +# Smallest plausible archive: 6-byte header plus one 12-byte entry and payload. +MIN_MIX_PROBE_SIZE = 64 +# Audio bag payloads carry no magic bytes and can accidentally satisfy MIX +# header validation, so they are recognized by CRC pairing with AUDIO.IDX. +AUDIO_INDEX_CRCES = frozenset( + crc for name in ("audio.idx",) for crc in (ra2_mix_hash(name), classic_mix_hash(name)) +) +AUDIO_BAG_CRCES = frozenset( + crc for name in ("audio.bag",) for crc in (ra2_mix_hash(name), classic_mix_hash(name)) +) + + +def _probe_mix(payload: memoryview) -> MixIndex | None: + """Return a parsed MIX index for payloads that parse as MIX, else None.""" + try: + return parse_mix(payload) + except Ra2ExplorerError: + return None class NameResolver: @@ -302,12 +321,27 @@ def _index_archive( ) ) - direct_assets: dict[str, tuple[AssetRecord, memoryview]] = {} + audio_index_pair: tuple[AssetRecord, memoryview] | None = None + audio_bag_pair: tuple[AssetRecord, memoryview] | None = None for entry in index.entries: name = resolved_names.get(entry.crc) payload = index.payload(data, entry) asset_format = format_from_name(name) confidence = "name" if asset_format else "unknown" + is_audio_bag = entry.crc in AUDIO_BAG_CRCES + if ( + name is None + and not is_audio_bag + and entry.size >= MIN_MIX_PROBE_SIZE + and _probe_mix(payload) is not None + ): + # Unnamed retail entries inside ra2.mix/ra2md.mix/language.mix + # are often encrypted nested MIX archives. Their headers carry + # no recognizable extension, so probe the MIX parser before + # falling back to content sniffing; otherwise the archive is + # misclassified and its members are lost to the index. + asset_format = "mix" + confidence = "content" if not asset_format and entry.size <= MAX_SNIFF_BYTES: asset_format = sniff_format(payload, name) if asset_format != "binary": @@ -336,8 +370,10 @@ def _index_archive( loose_relative_path=None, ) result.assets.append(asset_record) - if name: - direct_assets[Path(name).name.casefold()] = (asset_record, payload) + if entry.crc in AUDIO_INDEX_CRCES: + audio_index_pair = (asset_record, payload) + elif is_audio_bag: + audio_bag_pair = (asset_record, payload) if asset_format != "mix" or depth >= MAX_NESTING_DEPTH: continue @@ -353,15 +389,13 @@ def _index_archive( result=result, ) - audio_index = direct_assets.get("audio.idx") - audio_bag = direct_assets.get("audio.bag") - if audio_index and audio_bag: + if audio_index_pair is not None and audio_bag_pair is not None: try: self._expand_audio_bag( source_id, - audio_bag[0], - audio_index[1], - len(audio_bag[1]), + audio_bag_pair[0], + audio_index_pair[1], + audio_bag_pair[0].size, result, ) except Ra2ExplorerError as error: diff --git a/src/ra2_explorer/reference_data.py b/src/ra2_explorer/reference_data.py index cabd283..f9ae2a7 100644 --- a/src/ra2_explorer/reference_data.py +++ b/src/ra2_explorer/reference_data.py @@ -51,16 +51,26 @@ BUILTIN_NAMES = ( "ra2.mix", + "ra2md.mix", "language.mix", + "langmd.mix", "multi.mix", "cache.mix", + "cachemd.mix", "local.mix", "neutral.mix", "conquer.mix", "generic.mix", "isogen.mix", + "sidec01.mix", + "sidec02.mix", + "audiomd.mix", "cameo.mix", "audio.mix", + "snow.mix", + "urban.mix", + "desert.mix", + "lunar.mix", "rules.ini", "art.ini", "ra2.csf", @@ -170,7 +180,7 @@ def load_audio_transcript( if original_text and not translated_text: translated_text = translations_by_original.get(original_text.strip()) or ( translations_by_normalized_original.get( - _audio_translation_lookup_key(original_text) + audio_translation_lookup_key(original_text) ) ) if translated_text: @@ -182,6 +192,31 @@ def load_audio_transcript( return entries +def load_audio_translations( + *, supplement_paths: tuple[Path, ...] +) -> dict[str, str]: + """Collect the spoken-line translations, keyed by the normalized original. + + ``load_audio_transcript`` can only backfill translations for file ids that + already appear in a transcript; this map covers the remaining lines the + game itself ships. + """ + translations: dict[str, str] = {} + for supplement_path in supplement_paths: + for original, translated in _load_audio_translations_by_original(supplement_path).items(): + translations.setdefault(audio_translation_lookup_key(original), translated) + translations.update(_load_audio_translations_by_normalized_original(supplement_path)) + return translations + + +def translated_text_for(original_text: str, translations: dict[str, str]) -> str | None: + """Look up a translation for a spoken line, aligning terminal punctuation.""" + translated = translations.get(audio_translation_lookup_key(original_text)) + if not translated: + return None + return _align_translation_punctuation(original_text, translated) + + def _load_audio_translations_by_original(path: Path) -> dict[str, str]: if not path.is_file(): return {} @@ -225,16 +260,16 @@ def _load_audio_translations_by_normalized_original(path: Path) -> dict[str, str if not isinstance(raw_translations, dict): return {} return { - _audio_translation_lookup_key(original): translated.strip() + audio_translation_lookup_key(original): translated.strip() for original, translated in raw_translations.items() if isinstance(original, str) - and _audio_translation_lookup_key(original) + and audio_translation_lookup_key(original) and isinstance(translated, str) and translated.strip() } -def _audio_translation_lookup_key(text: str) -> str: +def audio_translation_lookup_key(text: str) -> str: return " ".join(re.findall(r"[\w]+", text.casefold().replace("_", " "))) @@ -474,8 +509,10 @@ def reference_status(path: Path) -> dict[str, object]: "BUNDLED_UNIT_VOICE_TRANSCRIPT_PATH", "BUNDLED_VOICE_TRANSLATION_PATH", "load_audio_transcript", + "load_audio_translations", "load_known_names", "reference_status", "sync_audio_transcript", "sync_known_names", + "translated_text_for", ] diff --git a/src/ra2_explorer/semantic.py b/src/ra2_explorer/semantic.py index 2846fe9..c7cec82 100644 --- a/src/ra2_explorer/semantic.py +++ b/src/ra2_explorer/semantic.py @@ -36,12 +36,13 @@ localized_mixed_search_match, pinyin_search_aliases, ) +from ra2_explorer.reference_data import translated_text_for from ra2_explorer.storage import Database ENTITY_KINDS = ("vehicle", "infantry", "aircraft", "building") ENTITY_USAGES = ("buildable", "hero", "tech", "civilian", "scenario") UNAFFILIATED_SIDE = "unaffiliated" -SEMANTIC_CATALOG_CACHE_IDENTITY = ("semantic-catalog-v23",) +SEMANTIC_CATALOG_CACHE_IDENTITY = ("semantic-catalog-v24",) _PLANNING_SIDE_IDS = ("GDI", "Nod", "ThirdSide") _TYPE_SECTIONS = { "vehicle": "VehicleTypes", @@ -750,13 +751,15 @@ def __init__( database: Database, reader: AssetReader, voice_transcripts: dict[str, dict[str, str]] | None = None, + voice_translations: dict[str, str] | None = None, ): self.database = database self.reader = reader self.voice_transcripts = voice_transcripts or {} + self.voice_translations = voice_translations or {} self._voice_transcript_revision = hashlib.sha256( json.dumps( - self.voice_transcripts, + (self.voice_transcripts, self.voice_translations), ensure_ascii=False, sort_keys=True, separators=(",", ":"), @@ -984,7 +987,7 @@ def list_media( "total": total, "kinds": [ {"kind": media_kind, "count": kind_counts.get(media_kind, 0)} - for media_kind in ("voice", "sound", "unknown") + for media_kind in ("voice", "sound", "music", "unknown") ], "groups": [ {"group": media_group, "count": count} @@ -1639,13 +1642,14 @@ def _build(self, source_id: str) -> SemanticCatalog: warnings = [] rules_assets = _edition_ini_inputs( - asset_index.by_name, + asset_index, "rules.ini", "rulesmd.ini", ) - art_assets = _named_inputs(asset_index.by_name, ("art.ini", "artmd.ini")) - sound_assets = _named_inputs(asset_index.by_name, ("sound.ini", "soundmd.ini")) - eva_assets = _named_inputs(asset_index.by_name, ("eva.ini", "evamd.ini")) + art_assets = _named_inputs(asset_index, ("art.ini", "artmd.ini")) + sound_assets = _named_inputs(asset_index, ("sound.ini", "soundmd.ini")) + eva_assets = _named_inputs(asset_index, ("eva.ini", "evamd.ini")) + theme_assets = _named_inputs(asset_index, ("theme.ini", "thememd.ini")) csf_assets = sorted( (asset for asset in assets if asset["format"] == "csf"), key=_config_precedence, @@ -1654,11 +1658,13 @@ def _build(self, source_id: str) -> SemanticCatalog: art = _merge_ini_inputs(self.reader, art_assets, warnings) sounds = _merge_ini_inputs(self.reader, sound_assets, warnings) eva = _merge_ini_inputs(self.reader, eva_assets, warnings) + themes = _merge_ini_inputs(self.reader, theme_assets, warnings) strings, voice_strings = _merge_csf_inputs( self.reader, csf_assets, warnings, self.voice_transcripts, + self.voice_translations, ) audio_events = _build_audio_events(sounds, asset_index, voice_strings) country_definitions = _build_country_definitions(rules, strings) @@ -1760,6 +1766,7 @@ def _build(self, source_id: str) -> SemanticCatalog: "art": tuple(_input_summary(asset) for asset in art_assets), "sound": tuple(_input_summary(asset) for asset in sound_assets), "eva": tuple(_input_summary(asset) for asset in eva_assets), + "theme": tuple(_input_summary(asset) for asset in theme_assets), "csf": tuple(_input_summary(asset) for asset in csf_assets), } if not rules_assets: @@ -1772,6 +1779,8 @@ def _build(self, source_id: str) -> SemanticCatalog: audio_events, eva_events, voice_strings, + strings, + _theme_entries(themes), ) return SemanticCatalog( source_id, @@ -2359,12 +2368,44 @@ def _mission_event_slot(asset_stem: str, events: Iterable[str]) -> str: return "mission_dialogue" +@dataclass(frozen=True, slots=True) +class ThemeEntry: + stem: str + title: str | None + + +def _theme_entries(themes: dict[str, dict[str, str]]) -> tuple[ThemeEntry, ...]: + """Bind THEME.INI soundtrack entries to their audio file stems. + + Retail THEME.INI keeps a [Themes] index plus one section per track whose + Sound key names the audio file and whose Name key points at a CSF label. + """ + entries: list[ThemeEntry] = [] + seen: set[str] = set() + for section, values in themes.items(): + if section.casefold() == "themes": + continue + sound = str(values.get("sound") or "").strip() + if not sound: + continue + stem = sound.rsplit(".", 1)[0] + folded = stem.casefold() + if not folded or folded in seen: + continue + seen.add(folded) + title = str(values.get("name") or "").strip() or None + entries.append(ThemeEntry(stem, title)) + return tuple(entries) + + def _build_media_items( assets: list[dict[str, Any]], entities: tuple[GameEntity, ...], audio_events: dict[str, tuple[MediaSample, ...]], eva_events: tuple[MediaAssociation, ...], voice_strings: dict[str, VoiceText], + strings: dict[str, str] | None = None, + theme_entries: tuple[ThemeEntry, ...] = (), ) -> tuple[dict[str, object], ...]: entities_by_id = {entity.id.casefold(): entity for entity in entities} representatives: dict[str, dict[str, Any]] = {} @@ -2381,6 +2422,7 @@ def _build_media_items( "asset": asset, "voice": False, "sound": False, + "music": False, "groups": set(), "texts": set(), "original_texts": set(), @@ -2622,8 +2664,22 @@ def add_sample( state["events"].add("PropagandaTruck") state["slots"].add("ambient") + for entry in theme_entries: + state = states.get(f"{entry.stem}.wav".casefold()) or states.get( + f"{entry.stem}.aud".casefold() + ) + if state is None: + continue + state["music"] = True + state["groups"].add("theme_music") + state["events"].add(entry.stem) + state["slots"].add("theme") + title = strings.get(str(entry.title).casefold()) if strings and entry.title else None + if title: + state["texts"].add(title.strip()) + for state in states.values(): - if state["voice"] or state["sound"]: + if state["voice"] or state["sound"] or state["music"]: continue asset = state["asset"] stem = str(asset["display_name"]).rsplit(".", 1)[0].casefold() @@ -2636,6 +2692,14 @@ def add_sample( state["events"].add(event) state["slots"].add("explosion") continue + # Tracks that THEME.INI omits or comments out still ship inside the + # soundtrack archives, so archive membership is the reliable signal. + if "theme.mix" in virtual_path or "thememd.mix" in virtual_path: + state["music"] = True + state["groups"].add("theme_music") + state["events"].add(stem.upper()) + state["slots"].add("theme") + continue description = _LEGACY_INTERFACE_AUDIO_DESCRIPTIONS.get(stem) if ( description is not None @@ -2653,7 +2717,12 @@ def add_sample( items = [] for state in states.values(): - kind = "voice" if state["voice"] else "sound" if state["sound"] else "unknown" + kind = ( + "voice" if state["voice"] + else "sound" if state["sound"] + else "music" if state["music"] + else "unknown" + ) asset_stem = str(state["asset"]["display_name"]).rsplit(".", 1)[0].casefold() events = [] seen_events: set[str] = set() @@ -2681,6 +2750,7 @@ def add_sample( for group in refined_groups if (kind == "voice" and group.endswith("_voice")) or (kind == "sound" and group.endswith("_sound")) + or (kind == "music" and group.endswith("_music")) ) if kind == "voice" and len(groups) > 1 and "other_voice" in groups: groups.remove("other_voice") @@ -2750,22 +2820,39 @@ def add_sample( def _named_inputs( - by_name: dict[str, list[dict[str, Any]]], + asset_index: _AssetIndex, names: tuple[str, ...], ) -> list[dict[str, Any]]: - assets = [] + assets: list[dict[str, Any]] = [] + seen: set[str] = set() for name in names: - assets.extend(by_name.get(name.casefold(), ())) + candidates = list(asset_index.by_name.get(name.casefold(), ())) + if not candidates: + # Unnamed members are indexed under their CRC; resolve known + # configuration names through their MIX hashes as well. + try: + hashes = {ra2_mix_hash(name), classic_mix_hash(name)} + except ValueError: + continue + candidates = [ + asset + for crc in hashes + for asset in asset_index.by_crc.get(crc, ()) + ] + for asset in candidates: + if asset["id"] not in seen: + seen.add(asset["id"]) + assets.append(asset) return sorted(assets, key=_config_precedence) def _edition_ini_inputs( - by_name: dict[str, list[dict[str, Any]]], + asset_index: _AssetIndex, base_name: str, expansion_name: str, ) -> list[dict[str, Any]]: - expansion = _named_inputs(by_name, (expansion_name,)) - return expansion or _named_inputs(by_name, (base_name,)) + expansion = _named_inputs(asset_index, (expansion_name,)) + return expansion or _named_inputs(asset_index, (base_name,)) def _merge_ini_inputs( @@ -2793,6 +2880,7 @@ def _merge_csf_inputs( assets: list[dict[str, Any]], warnings: list[str], voice_transcripts: dict[str, dict[str, str]], + voice_translations: dict[str, str] | None = None, ) -> tuple[dict[str, str], dict[str, VoiceText]]: strings: dict[str, str] = {} voice_strings: dict[str, VoiceText] = {} @@ -2809,6 +2897,10 @@ def _merge_csf_inputs( strings[folded] = label.values[0].text if folded.startswith("vox:"): value = label.values[0] + # Chinese community installs often leave the CSF language + # header at 0; treat CJK text as the localized column so + # packaged Chinese voice lines are not misfiled as English. + is_localized = parsed.language == 9 or _contains_cjk(value.text) for alias in _voice_aliases(folded[4:], value.extra): current = voice_strings.get(alias) original_text = current.original_text if current else None @@ -2817,7 +2909,7 @@ def _merge_csf_inputs( current.localized_text_origin if current else None ) translated_text = current.translated_text if current else None - if parsed.language == 9: + if is_localized: localized_text = value.text localized_text_origin = "game" elif parsed.language == 0: @@ -2831,6 +2923,7 @@ def _merge_csf_inputs( translated_text, ) _overlay_voice_transcripts(voice_strings, voice_transcripts) + _overlay_voice_translations(voice_strings, voice_translations or {}) return strings, voice_strings @@ -2873,6 +2966,41 @@ def _overlay_voice_transcripts( ) +def _overlay_voice_translations( + voice_strings: dict[str, VoiceText], + translations: dict[str, str], +) -> None: + """Translate spoken lines the game ships but no transcript file lists. + + ``load_audio_transcript`` can only backfill translations for file ids that + already have a transcript entry, so lines that only exist in the game CSF + would otherwise stay untranslated even when a translation is available. + """ + if not translations: + return + for key, current in list(voice_strings.items()): + if current.translated_text or not current.original_text: + continue + translated_text = translated_text_for(current.original_text, translations) + if not translated_text: + continue + voice_strings[key] = VoiceText( + current.label, + translated_text if current.text == current.original_text else current.text, + current.original_text, + current.localized_text, + current.localized_text_origin, + translated_text, + ) + + +def _contains_cjk(text: str) -> bool: + return any( + 0x2E80 <= ord(character) <= 0x9FFF or 0xF900 <= ord(character) <= 0xFAFF + for character in text + ) + + def _voice_aliases(label_name: str, extra: str | None) -> tuple[str, ...]: aliases = [label_name.casefold()] for raw in re.split(r"[,;|\s]+", extra or ""): diff --git a/src/ra2_explorer/video.py b/src/ra2_explorer/video.py index 1270be9..0b7c3f8 100644 --- a/src/ra2_explorer/video.py +++ b/src/ra2_explorer/video.py @@ -6,11 +6,22 @@ import uuid from pathlib import Path +from PIL import Image + from ra2_explorer.derived import DerivedStore from ra2_explorer.errors import Ra2ExplorerError from ra2_explorer.library import AssetReader from ra2_explorer.storage import Database +# Posters skip the opening seconds because these movies fade in from black, +# then walk deeper until something readable shows up. The last attempt has no +# seek at all, so even a clip shorter than every seek still gets a poster. +POSTER_SEEK_SECONDS: tuple[float, ...] = (1, 2, 4, 6, 8) +# A frame where fewer than this share of pixels are brighter than the luma +# floor counts as black and is not worth showing. +POSTER_DARK_LUMA = 16 +POSTER_DARK_MAX_RATIO = 0.02 + class VideoTranscoder: """Create browser-compatible video derivatives without touching game files.""" @@ -45,59 +56,151 @@ def browser_video(self, asset_id: str) -> Path: _, source_path = self.reader.materialize(asset_id) output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_name(f".{output.stem}.{uuid.uuid4().hex}.tmp.mp4") - creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) - command = [ - ffmpeg, - "-hide_banner", - "-loglevel", - "error", - "-nostdin", - "-y", - "-i", - str(source_path), - "-map", - "0:v:0", - "-map", - "0:a:0?", - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "20", - "-pix_fmt", - "yuv420p", - "-c:a", - "aac", - "-b:a", - "160k", - "-movflags", - "+faststart", - str(temporary), - ] - try: - result = subprocess.run( - command, - check=False, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - timeout=300, - creationflags=creation_flags, - ) - if result.returncode != 0 or not temporary.is_file(): - detail = result.stderr.decode("utf-8", errors="replace").strip()[-600:] - raise Ra2ExplorerError(f"视频转换失败:{detail or 'FFmpeg 未生成输出'}") + self._run_ffmpeg( + [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + "-i", + str(source_path), + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "160k", + "-movflags", + "+faststart", + str(temporary), + ], + output, + "视频转换失败", + ) + return output + + def poster_frame(self, asset_id: str) -> Path: + """Extract one frame so video assets can show a preview before transcoding.""" + asset = self.database.get_asset(asset_id) + if asset["format"] != "video": + raise Ra2ExplorerError("该资产不是可转换的视频") + source = self.database.get_source(str(asset["source_id"])) + output = self.derived.artifact_path( + "video", + source_id=source["id"], + revision=source.get("scanned_at") or source["created_at"], + identity=(asset["id"], "poster-v2"), + extension="png", + ) + if output.is_file(): + return output + with self._lock_for(output): + if output.is_file(): + return output + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + raise Ra2ExplorerError("未找到 FFmpeg,无法生成视频封面") + _, source_path = self.reader.materialize(asset_id) + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.stem}.{uuid.uuid4().hex}.tmp.png") + self._capture_poster(ffmpeg, source_path, output, temporary) + return output + + def _capture_poster( + self, + ffmpeg: str, + source_path: Path, + output: Path, + temporary: Path, + ) -> None: + """Seek progressively deeper until the poster is not a black frame.""" + attempts: list[float | None] = [*POSTER_SEEK_SECONDS, None] + detail = "FFmpeg 未生成输出" + for seconds in attempts: + command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y"] + if seconds is not None: + command += ["-ss", f"{seconds:g}"] + command += ["-i", str(source_path), "-frames:v", "1", str(temporary)] + ok, attempt_detail = self._extract_frame(command) + if ok and (seconds is None or not _frame_is_dark(temporary)): self.derived.commit_file(output, temporary) - except subprocess.TimeoutExpired as error: - raise Ra2ExplorerError("视频转换超过 5 分钟,已停止") from error - finally: temporary.unlink(missing_ok=True) - return output + return + temporary.unlink(missing_ok=True) + detail = attempt_detail or detail + raise Ra2ExplorerError(f"视频封面生成失败:{detail or '未找到可用的画面帧'}") + + def _extract_frame(self, command: list[str]) -> tuple[bool, str]: + """Extract a single frame without publishing it.""" + try: + result = subprocess.run( + command, + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=120, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except subprocess.TimeoutExpired: + return False, "单帧抽取超过 120 秒已停止" + detail = result.stderr.decode("utf-8", errors="replace").strip()[-600:] + if result.returncode != 0: + return False, detail or f"FFmpeg 退出码 {result.returncode}" + if not Path(command[-1]).is_file(): + # Seeking past the end of a very short clip yields no frame. + return False, detail or "FFmpeg 未生成输出" + return True, "" + + def _run_ffmpeg(self, command: list[str], output: Path, failure: str) -> None: + temporary = Path(command[-1]) + try: + result = subprocess.run( + command, + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=300, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + if result.returncode != 0 or not temporary.is_file(): + detail = result.stderr.decode("utf-8", errors="replace").strip()[-600:] + raise Ra2ExplorerError(f"{failure}:{detail or 'FFmpeg 未生成输出'}") + self.derived.commit_file(output, temporary) + except subprocess.TimeoutExpired as error: + raise Ra2ExplorerError(f"{failure}:超过 5 分钟已停止") from error + finally: + temporary.unlink(missing_ok=True) def _lock_for(self, path: Path) -> threading.Lock: with self._locks_guard: return self._locks.setdefault(path, threading.Lock()) +def _frame_is_dark(path: Path) -> bool: + """Return True when a frame is essentially black, such as a fade-in.""" + try: + with Image.open(path) as image: + histogram = image.convert("L").histogram() + except OSError: + return True + total = sum(histogram) + if not total: + return True + return sum(histogram[POSTER_DARK_LUMA:]) / total < POSTER_DARK_MAX_RATIO + + __all__ = ["VideoTranscoder"] diff --git a/tests/test_api.py b/tests/test_api.py index 2fb678a..2aa4693 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,7 @@ import io import json from pathlib import Path +from types import SimpleNamespace from fastapi.testclient import TestClient from PIL import Image @@ -13,9 +14,12 @@ _composite_focus_bounds, _crop_transparent_preview, _default_entity_operation_samples, + _select_palette, create_app, ) +from ra2_explorer.codecs.pal import PLACEHOLDER_MAGENTA from ra2_explorer.config import Settings +from ra2_explorer.reference_data import audio_translation_lookup_key from ra2_explorer.semantic import ( GameEntity, MediaAssociation, @@ -35,6 +39,7 @@ _entity_usage, _index_assets, _overlay_voice_transcripts, + _overlay_voice_translations, _resolve_components, _shp_unit_body_playbacks, _standalone_sound_group, @@ -863,6 +868,96 @@ def test_verified_unit_intro_overrides_csf_unit_name_text() -> None: assert voice_strings["csofu39"].localized_text_origin == "game" +def test_translation_backfill_covers_lines_without_transcript_entries() -> None: + voice_strings = { + "cevau06": VoiceText( + "VOX:CEVAU06", + "The V3 is a powerful long-range artillery weapon.", + "The V3 is a powerful long-range artillery weapon.", + None, + "game", + None, + ) + } + + _overlay_voice_translations( + voice_strings, + { + audio_translation_lookup_key( + "The V3 is a powerful long-range artillery weapon." + ): "V3 是威力强大的远程火炮。" + }, + ) + + entry = voice_strings["cevau06"] + assert entry.translated_text == "V3 是威力强大的远程火炮。" + assert entry.original_text == "The V3 is a powerful long-range artillery weapon." + assert entry.text == "V3 是威力强大的远程火炮。" + assert entry.localized_text_origin == "game" + + +def test_translation_backfill_keeps_existing_and_nonverbal_entries() -> None: + voice_strings = { + "covered": VoiceText("VOX:COVERED", "Line", "Line", None, "game", "已有译文"), + "nonverbal": VoiceText("VOX:NONVERBAL", "<阵亡声>", None, None, None, "<阵亡声>"), + } + + _overlay_voice_translations( + voice_strings, + { + audio_translation_lookup_key("Line"): "不应覆盖", + audio_translation_lookup_key("<阵亡声>"): "不应出现", + }, + ) + + assert voice_strings["covered"].translated_text == "已有译文" + assert voice_strings["nonverbal"].translated_text == "<阵亡声>" + + +def test_palette_selection_avoids_magenta_placeholders() -> None: + def palette_data_with(colors: dict[int, tuple[int, int, int]]) -> bytes: + raw = bytearray() + for index in range(256): + red, green, blue = colors.get(index, (index, index, index)) + raw += bytes((min(63, red >> 2), min(63, green >> 2), min(63, blue >> 2))) + return bytes(raw) + + # 单位调色板把 204-239 填成洋红占位,地形调色板则是真实颜色。 + unit = palette_data_with({index: PLACEHOLDER_MAGENTA for index in range(204, 240)}) + iso = palette_data_with({204: (68, 56, 52)}) + palettes = [ + {"id": "unit-ubn", "display_name": "UNITUBN.PAL"}, + {"id": "unit-tem", "display_name": "unittem.pal"}, + {"id": "iso-ubn", "display_name": "ISOUBN.PAL"}, + {"id": "iso-tem", "display_name": "isotem.pal"}, + ] + data = { + "unit-ubn": unit, + "unit-tem": unit, + "iso-ubn": iso, + "iso-tem": palette_data_with({}), + } + services = SimpleNamespace( + database=SimpleNamespace(palette_assets=lambda source_id: palettes), + reader=SimpleNamespace(read=lambda asset_id: (None, data[asset_id])), + ) + asset = { + "id": "urban-object", + "source_id": "source", + "format": "shp", + "extension": "pak", + "virtual_path": "ra2md.mix/URBANN.MIX::00102:0ABB14BC-G.PAK", + } + + # 缺省时沿用单位调色板;画面用到占位索引时改用地形调色板。 + assert _select_palette(services, asset, None, None).colors[204] == PLACEHOLDER_MAGENTA + scored = _select_palette(services, asset, None, None, used_indices={204, 130}) + assert scored.colors[204] == (68, 56, 52) + # 明确的调色板用途(如 art.ini 声明的单位动画)仍然优先单位调色板。 + forced = _select_palette(services, asset, None, "unit", used_indices={204}) + assert forced.colors[204] == PLACEHOLDER_MAGENTA + + def test_eva_media_groups_separate_missions_and_nonverbal_prompts() -> None: def asset(name: str) -> dict[str, object]: return { @@ -1675,13 +1770,16 @@ def test_expansion_rules_replace_the_base_edition() -> None: "storage_kind": "mix", } + def index_for(by_name: dict[str, list[dict[str, object]]]) -> _AssetIndex: + return _AssetIndex(by_name=dict(by_name), by_crc={}) + assert _edition_ini_inputs( - {"rules.ini": [base], "rulesmd.ini": [expansion]}, + index_for({"rules.ini": [base], "rulesmd.ini": [expansion]}), "rules.ini", "rulesmd.ini", ) == [expansion] assert _edition_ini_inputs( - {"rules.ini": [base]}, + index_for({"rules.ini": [base]}), "rules.ini", "rulesmd.ini", ) == [base] diff --git a/tests/test_library_scan.py b/tests/test_library_scan.py new file mode 100644 index 0000000..b42bb52 --- /dev/null +++ b/tests/test_library_scan.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path + +from ra2_explorer.codecs.mix import build_mix +from ra2_explorer.codecs.sniff import sniff_format +from ra2_explorer.library import SourceLibrary +from ra2_explorer.storage import Database + + +def test_unnamed_encrypted_nested_mix_is_traversed(tmp_path: Path) -> None: + source_dir = tmp_path / "game" + source_dir.mkdir() + inner = build_mix( + ( + ("fixtone.wav", b"\x00" * 64), + ("fixture.ini", b"[General]\nName=Fixture\n"), + ), + encrypted=True, + ) + decoy = b"A" * 2048 + outer = build_mix((("sideblob.dat", inner), ("decoy.bin", decoy))) + (source_dir / "outer.mix").write_bytes(outer) + + database = Database(tmp_path / "index.db") + source = SourceLibrary( + database, ("fixtone.wav", "fixture.ini") + ).import_source(source_dir) + items = database.list_assets(source_id=str(source["id"]), limit=500)["items"] + + formats = {asset["display_name"]: asset["format"] for asset in items} + assert formats["fixtone.wav"] == "wav" + assert formats["fixture.ini"] == "ini" + # The decoy has no resolvable name and must not be mistaken for a MIX. + assert sum(asset_format == "binary" for asset_format in formats.values()) == 1 + nested_mix_names = [ + name for name, asset_format in formats.items() + if asset_format == "mix" and name.startswith("crc_") + ] + assert len(nested_mix_names) == 1 + + +def test_sniff_detects_bink_video() -> None: + assert sniff_format(b"BIKi" + b"\x00" * 64, None) == "video" + + +def _csf_bytes(language: int, labels: tuple[tuple[str, str, str | None], ...]) -> bytes: + import struct + + output = bytearray(b" FSC") + output.extend(struct.pack(" None: + self._payloads = payloads + + def read(self, asset_id: str) -> tuple[str, bytes]: + return asset_id, self._payloads[asset_id] + + +def test_csf_voice_text_falls_back_to_cjk_detection() -> None: + from ra2_explorer.semantic import _merge_csf_inputs + + english = _csf_bytes( + 0, + (("VOX:apocsea", "Soviet power supreme", "vaposea"),), + ) + chinese = _csf_bytes( + 0, + (("VOX:apocsea", "苏维埃联盟万岁", "vaposea"),), + ) + assets = [ + {"id": "csf-english", "display_name": "ra2.csf", "virtual_path": "ra2.csf"}, + {"id": "csf-chinese", "display_name": "ra2zh.csf", "virtual_path": "ra2zh.csf"}, + ] + reader = _FakeReader({"csf-english": english, "csf-chinese": chinese}) + + _, voice_strings = _merge_csf_inputs(reader, assets, [], {}) + + voice = voice_strings["vaposea"] + assert voice.original_text == "Soviet power supreme" + assert voice.localized_text == "苏维埃联盟万岁" + + +def test_unnamed_audio_bag_pair_is_expanded(tmp_path: Path) -> None: + import struct + + source_dir = tmp_path / "game" + source_dir.mkdir() + bag_payload = b"\x00" * 64 + idx_payload = b"GABA" + struct.pack( + " None: +def _transcoder_with_video(tmp_path: Path): source_dir = tmp_path / "game" source_dir.mkdir() - original = b"BIK synthetic test payload" - (source_dir / "intro.bik").write_bytes(original) + (source_dir / "intro.bik").write_bytes(b"BIK synthetic test payload") database = Database(tmp_path / "index.db") source = SourceLibrary(database, ()).import_source(source_dir) asset = database.list_assets( @@ -23,6 +24,17 @@ def test_video_transcode_is_on_demand_cached_and_derived(tmp_path: Path, monkeyp derived = DerivedStore(tmp_path / "RA2MD-Ext") reader = AssetReader(database, derived) transcoder = VideoTranscoder(database, reader, derived) + return transcoder, str(asset["id"]), derived + + +def _write_frame(path: Path, luma: int) -> None: + Image.new("L", (4, 4), luma).save(path) + + +def test_video_transcode_is_on_demand_cached_and_derived(tmp_path: Path, monkeypatch) -> None: + transcoder, asset_id, derived = _transcoder_with_video(tmp_path) + source_dir = tmp_path / "game" + original = (source_dir / "intro.bik").read_bytes() calls = [] monkeypatch.setattr("ra2_explorer.video.shutil.which", lambda _name: "ffmpeg.exe") @@ -34,8 +46,8 @@ def run(command, **_kwargs): monkeypatch.setattr("ra2_explorer.video.subprocess.run", run) - first = transcoder.browser_video(str(asset["id"])) - second = transcoder.browser_video(str(asset["id"])) + first = transcoder.browser_video(asset_id) + second = transcoder.browser_video(asset_id) assert first == second assert first.read_bytes() == b"browser mp4" @@ -43,3 +55,95 @@ def run(command, **_kwargs): assert (source_dir / "intro.bik").read_bytes() == original assert len(calls) == 1 assert "-nostdin" in calls[0] + + +def test_video_poster_is_extracted_once_and_cached(tmp_path: Path, monkeypatch) -> None: + transcoder, asset_id, derived = _transcoder_with_video(tmp_path) + calls = [] + + monkeypatch.setattr("ra2_explorer.video.shutil.which", lambda _name: "ffmpeg.exe") + + def run(command, **_kwargs): + calls.append(command) + _write_frame(Path(command[-1]), 200) + return SimpleNamespace(returncode=0, stderr=b"") + + monkeypatch.setattr("ra2_explorer.video.subprocess.run", run) + + first = transcoder.poster_frame(asset_id) + second = transcoder.poster_frame(asset_id) + + assert first == second + assert first.suffix == ".png" + assert first.is_relative_to(derived.root) + assert len(calls) == 1 + # Skips the opening fade-in and stops after a single frame. + assert calls[0][calls[0].index("-ss") + 1] == "1" + assert calls[0][calls[0].index("-frames:v") + 1] == "1" + + +def test_video_poster_walks_deeper_past_black_frames(tmp_path: Path, monkeypatch) -> None: + transcoder, asset_id, _ = _transcoder_with_video(tmp_path) + calls = [] + + monkeypatch.setattr("ra2_explorer.video.shutil.which", lambda _name: "ffmpeg.exe") + + def run(command, **_kwargs): + calls.append(command) + _write_frame(Path(command[-1]), 0) + return SimpleNamespace(returncode=0, stderr=b"") + + monkeypatch.setattr("ra2_explorer.video.subprocess.run", run) + + poster = transcoder.poster_frame(asset_id) + + # Every seek lands on a black frame, so it falls through to the unseeked + # first frame instead of giving up. + assert len(calls) == len(POSTER_SEEK_SECONDS) + 1 + assert all("-ss" in call for call in calls[:-1]) + assert "-ss" not in calls[-1] + assert poster.is_file() + + +def test_video_poster_survives_clips_shorter_than_the_seeks( + tmp_path: Path, monkeypatch +) -> None: + transcoder, asset_id, _ = _transcoder_with_video(tmp_path) + calls = [] + + monkeypatch.setattr("ra2_explorer.video.shutil.which", lambda _name: "ffmpeg.exe") + + def run(command, **_kwargs): + calls.append(command) + if "-ss" in command: + # Seeking past the end of a short clip produces no output. + return SimpleNamespace(returncode=0, stderr=b"") + _write_frame(Path(command[-1]), 200) + return SimpleNamespace(returncode=0, stderr=b"") + + monkeypatch.setattr("ra2_explorer.video.subprocess.run", run) + + poster = transcoder.poster_frame(asset_id) + + assert all("-ss" in call for call in calls[:-1]) + assert "-ss" not in calls[-1] + assert poster.is_file() + + +def test_video_poster_reports_failure_when_nothing_is_extracted( + tmp_path: Path, monkeypatch +) -> None: + import pytest + + transcoder, asset_id, _ = _transcoder_with_video(tmp_path) + from ra2_explorer.errors import Ra2ExplorerError + + monkeypatch.setattr("ra2_explorer.video.shutil.which", lambda _name: "ffmpeg.exe") + + def run(command, **_kwargs): + return SimpleNamespace(returncode=1, stderr=b"boom") + + monkeypatch.setattr("ra2_explorer.video.subprocess.run", run) + + with pytest.raises(Ra2ExplorerError, match="视频封面生成失败"): + transcoder.poster_frame(asset_id)