Compare commits

..
2 Commits
Author SHA1 Message Date
Patryk Gensch 8a870a60bb feat: add asset browser and ANN event playback 2026-08-25 00:01:21 +02:00
Patryk GenschandClaude Opus 5 c2036e1f72 Add a README
Everything so far lived in code comments and CLI help, which works while writing
the thing and not at all when coming back to it after a month. Covers what the
catalogue is, where its knowledge of the Aidem formats comes from (:core through
a composite build, never a fork), how the data is shaped, and how to run it —
pipeline, browsing, transcription, Docker, and moving the database between
machines.

Written against the actual state rather than memory: the command list is what the
binary prints, the table list is what the schema holds, and the submodule URL is
the one in .gitmodules. Volatile figures are left out, with a pointer to
`rex-catalog stats` instead, so the file does not rot on the next ingest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:53:04 +02:00
6 changed files with 930 additions and 88 deletions
+168
View File
@@ -0,0 +1,168 @@
# rex-catalog
Katalog kolekcji gier Aidem Media — serii *Reksio* i *Poznaj Mity*. Indeksuje obrazy płyt,
zagląda do środka i pozwala je przeglądać: odszyfrowane skrypty, odtwarzalne kwestie
mówione, podgląd grafiki i animacji oraz powiązania między nimi.
Powstał do celów archiwizacyjnych i testów emulatora. Niczego nie modyfikuje w kolekcji —
obrazy płyt otwiera wyłącznie do odczytu, a wszystko, co wytworzy, ląduje w osobnym
katalogu `data/`.
## Skąd bierze wiedzę o formatach
Formaty Aidem — zaszyfrowane skrypty CNV, obrazy `PIK\0`, animacje `NVP\0`, kompresje
CLZW i CRLE — czyta przez `:core` z [Rex-EMoolatora](https://github.com/patryk025/Rex-EMoolator),
podpiętego jako **composite build**, a nie fork. Emulator jest przypięty submodułem na
konkretnym tagu (`gradle.properties``coreVersion`), a zadanie `verifyCoreVersion`
pilnuje, żeby tag i submoduł się nie rozjechały.
Dzięki temu wiedza o formatach ma jedno źródło. Tam, gdzie katalog musiał napisać własny
parser, jest to powiedziane wprost w komentarzu razem z powodem — najczęściej dlatego,
że loader z `:core` buduje tekstury OpenGL-a i w procesie bez okna nie da się go użyć.
## Jak to jest poukładane
**Trzy poziomy tożsamości.** `title` (dzieło) → `edition` (wydanie) → `copy` (konkretny
plik na dysku). Między wydaniem a kopią stoi jeszcze `game_root`, bo jedna płyta bywa
dwupackiem z dwiema grami, każdą z własnym silnikiem.
**Plik osobno, treść osobno.** `file` mówi, gdzie coś leży, `blob` — czym jest. Porównanie
dwóch wydań jest więc operacją na zbiorach odcisków, a wszystko, co wytworzone
(odszyfrowany skrypt, miniatura), kluczuje się treścią: ten sam plik na trzech płytach
przerabiamy raz.
**Fakty z dowodem.** Detektory nie nadpisują pól, tylko dopisują wiersze do `detection`
z wartością, pewnością i dowodem. Poprawki człowieka siedzą osobno, w polach `*_override`,
i nigdy nie są nadpisywane przez kolejny przebieg.
**Nic nie wypakowujemy.** Dźwięk i klatki animacji czytamy z obrazu płyty na żądanie,
sięgając od razu na właściwą pozycję. W `data/` zostaje tylko to, co drogo policzyć:
odszyfrowane skrypty i miniatury.
## Uruchomienie
Potrzebne: **JDK 21** i submoduł z emulatorem.
```bash
git submodule update --init --recursive
./gradlew installDist
```
Dalej wygodnie przez launcher (`build/install/rex-catalog/bin/rex-catalog`) albo
`./gradlew run --args="..."`. Katalog bazy wskazuje `-Dcatalog.data` (domyślnie `./data`).
### Potok
```bash
rex-catalog ingest /ścieżka/do/kolekcji # indeksuje obrazy, katalogi gier i całe kolekcje
rex-catalog decode # odszyfrowuje skrypty do cache'u i indeksu FTS5
rex-catalog probe # dźwięk, grafika, dialogi, powiązania
rex-catalog analyze # metadane z application.def i install.ini
rex-catalog promote # tworzy tytuły i wydania z wykrytych faktów
```
Każdy krok jest **przyrostowy** — dołożenie nowej płyty przerabia tylko ją. Ponowne
uruchomienie bez `--force` nie robi nic, jeśli nic się nie zmieniło.
`ingest` przyjmuje obraz `.iso` (ISO 9660, Joliet i UDF), archiwum `.zip`, katalog
rozpakowanej gry albo katalog z całą kolekcją.
### Przeglądanie
```bash
rex-catalog serve # front i MCP na http://127.0.0.1:8765
rex-catalog find CANVAS_OBSERVER # przeszukanie skryptów (składnia FTS5)
rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście
rex-catalog titles # lista wydań z metadanymi
```
Front otwiera się na widoku **Zasoby** — paginowanej galerii obrazów, animacji i nagrań,
filtrowanej po grze, rodzaju i nazwie. Zaznaczenie otwiera inspektor z pełnym podglądem,
parametrami i miejscami użycia. Animacje można odtwarzać w tempie zapisanym w ANN — jako
wszystkie obrazy albo konkretne nazwane zdarzenie, np. `BEZRUCH` czy `GADA_1`, razem z jego
właściwą kolejnością klatek i zakresem pętli. Dalej są **Skrypty** (wyszukiwanie
pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie nagrań,
odtwarzacz, transkrypcja), **Kolekcja** (tytuły, wydania, kopie, pliki) i **Statystyki**.
Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami —
od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt
odtwarza, kto to mówi i jak długo trwa.
### Metadane ręczne
Tego, czego nie da się wykryć, nie zgadujemy:
```bash
rex-catalog set copy "Wojna Troj" source_kind=wlasny_zgraj rip_tool=dd
rex-catalog set edition 3 distributor="Aidem Media" release_date_override=2003-11-18
rex-catalog lang add 3 cs audio
```
## Transkrypcja mowy
Opcjonalna i uruchamiana świadomie — wymaga [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
i modelu, których repozytorium nie dostarcza.
```bash
brew install whisper-cpp # albo dowolna inna instalacja
rex-catalog transcribe --limit 100 # -Dcatalog.whisper.model=ścieżka/do/ggml-medium.bin
```
Da się też wyklikać z zakładki **Dźwięk** — z paskiem postępu i zatrzymaniem, które nie
gubi tego, co już policzone.
Kwestie są sklejane po kilka w trzydziestosekundowe okna z sekundą ciszy między nimi,
a wynik rozcinany z powrotem po znacznikach czasu. Whisper koduje zawsze pełne okno,
więc podawanie mu czterosekundowych kwestii po jednej marnuje większość pracy.
Transkrypt jest **wygenerowany, nie odczytany z płyty**. Siedzi w osobnej tabeli razem
z nazwą modelu i narzędzia, a interfejs oznacza go jako maszynowy. Zdania, które model
dopisuje z siebie na ciszy — podpisy ekip od napisów — są odsiewane.
## Docker
```bash
COLLECTION=/ścieżka/do/kolekcji docker compose up -d
docker compose exec katalog /app/entrypoint.sh ingest /media
```
Kolekcja montuje się tylko do odczytu, baza i cache lądują w wolumenie. Obraz niesie
whisper.cpp i ffmpeg, ale **nie model** — ten podmontuj pod `/models` i wskaż zmienną
`WHISPER_MODEL`.
`Dockerfile.cuda` buduje wariant z akceleracją NVIDII (domyślnie pod compute capability
8.6, czyli GeForce RTX 30). Na macOS akceleracja w kontenerze nie działa — tam transkrypcję
uruchamiaj na hoście, gdzie whisper.cpp sam korzysta z Metala.
## Przenoszenie między maszynami
Baza zapisuje bezwzględne ścieżki do obrazów płyt, bo czyta z nich dźwięk i grafikę.
Po skopiowaniu `data/` na inną maszynę trzeba ją przypiąć do kolekcji:
```bash
rex-catalog relocate D:\Reksio --dry-run # najpierw pokaż, co zrobisz
rex-catalog relocate D:\Reksio
```
Dopasowuje po nazwie i sprawdza rozmiar (`--verify` dokłada SHA-256), a nazwy porównuje po
normalizacji Unicode — bez tego kolekcja z macOS nie dogaduje się z tą samą kolekcją na
Windowsie, bo `ń` jest tam zapisane inaczej.
## Co siedzi w bazie
SQLite, jeden plik obok cache'u, więc przeniesienie katalogu to skopiowanie `data/`.
| tabela | co trzyma |
|---|---|
| `title`, `edition`, `copy`, `game_root` | tożsamość: dzieło, wydanie, nośnik, korzeń gry |
| `file`, `blob` | gdzie plik leży i czym jest |
| `detection` | fakty detektorów z dowodem i pewnością |
| `artifact` | wytworzone: odszyfrowane skrypty, miniatury |
| `media` | fakty o zasobie: długość, wymiary, klatki, kodek |
| `archive_entry` | zawartość archiwów `.snd` — kwestie spakowane w jeden plik |
| `script_ref` | odwołania skryptów do zasobów |
| `dialogue_line`, `anim_event` | mówca i zdarzenie kwestii, nazwane sekwencje animacji |
| `transcript` | rozpoznana mowa, osobno od reszty |
| `script_fts`, `transcript_fts` | indeksy pełnotekstowe |
`rex-catalog stats` pokazuje, co w danej chwili jest w środku.
@@ -112,6 +112,11 @@ public final class Database implements AutoCloseable {
addColumnIfMissing("media", "frames", "INTEGER");
addColumnIfMissing("media", "fps", "INTEGER");
addColumnIfMissing("media", "author", "TEXT");
// Starszy indeks zdarzeń przechowywał tylko ich długość. Do wiernego
// podglądu potrzebna jest właściwa kolejność obrazów i reguły pętli.
addColumnIfMissing("anim_event", "frames", "TEXT");
addColumnIfMissing("anim_event", "repeat_count", "INTEGER");
addColumnIfMissing("anim_event", "flags", "INTEGER");
addBasenameIndex();
}
@@ -412,6 +417,9 @@ public final class Database implements AutoCloseable {
frames_count INTEGER,
loop_start INTEGER,
loop_end INTEGER,
frames TEXT,
repeat_count INTEGER,
flags INTEGER,
PRIMARY KEY (ann_sha1, name)
)
"""
@@ -36,7 +36,8 @@ import java.util.Set;
*/
public final class GraphicsIndexer {
public static final String VERSION = "grafika-1";
public static final String IMAGE_VERSION = "grafika-1";
public static final String ANIMATION_VERSION = "grafika-2";
private static final String KIND = "preview";
@@ -93,11 +94,13 @@ public final class GraphicsIndexer {
PreparedStatement eventDelete = conn.prepareStatement(
"DELETE FROM anim_event WHERE ann_sha1 = ?");
PreparedStatement eventInsert = conn.prepareStatement("""
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end)
VALUES (?, ?, ?, ?, ?)
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end,
frames, repeat_count, flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ann_sha1, name) DO UPDATE SET
frames_count = excluded.frames_count, loop_start = excluded.loop_start,
loop_end = excluded.loop_end
loop_end = excluded.loop_end, frames = excluded.frames,
repeat_count = excluded.repeat_count, flags = excluded.flags
""")) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
@@ -152,11 +155,11 @@ public final class GraphicsIndexer {
private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt,
PreparedStatement artifactStmt) throws Exception {
PikGraphics.Picture picture = PikGraphics.readImage(raw);
writePreview(sha1, picture.image(), artifactStmt);
writePreview(sha1, picture.image(), artifactStmt, IMAGE_VERSION);
write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(),
picture.height(), 1, 0, null,
"kompresja " + picture.compression());
"kompresja " + picture.compression(), IMAGE_VERSION);
}
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
@@ -164,12 +167,14 @@ public final class GraphicsIndexer {
PreparedStatement eventInsert) throws Exception {
PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
if (!animation.frames().isEmpty()) {
writePreview(sha1, animation.frames().get(0).image(), artifactStmt);
writePreview(sha1, animation.frames().get(0).image(), artifactStmt,
ANIMATION_VERSION);
}
write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(),
animation.maxHeight(), animation.frames().size(), animation.fps(),
blankToNull(animation.author()), blankToNull(animation.description()));
blankToNull(animation.author()), blankToNull(animation.description()),
ANIMATION_VERSION);
eventDelete.setString(1, sha1);
eventDelete.executeUpdate();
@@ -179,13 +184,17 @@ public final class GraphicsIndexer {
eventInsert.setInt(3, event.framesCount());
eventInsert.setInt(4, event.loopStart());
eventInsert.setInt(5, event.loopEnd());
eventInsert.setString(6, event.frames().stream()
.map(String::valueOf).collect(java.util.stream.Collectors.joining(",")));
eventInsert.setInt(7, event.repeatCount());
eventInsert.setInt(8, event.flags());
eventInsert.executeUpdate();
}
return animation.frames().size();
}
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt)
throws Exception {
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt,
String version) throws Exception {
String relative = cachePath(sha1);
Path target = dataDir.resolve(relative);
Files.createDirectories(target.getParent());
@@ -194,7 +203,7 @@ public final class GraphicsIndexer {
artifactStmt.setString(1, sha1);
artifactStmt.setString(2, KIND);
artifactStmt.setString(3, relative);
artifactStmt.setString(4, VERSION);
artifactStmt.setString(4, version);
artifactStmt.setString(5, Instant.now().toString());
artifactStmt.executeUpdate();
}
@@ -223,12 +232,13 @@ public final class GraphicsIndexer {
throws Exception {
String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage();
write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image",
null, 0, 0, 0, 0, null, "nieczytelny: " + reason);
null, 0, 0, 0, 0, null, "nieczytelny: " + reason,
"ANN".equals(pending.format()) ? ANIMATION_VERSION : IMAGE_VERSION);
}
private void write(PreparedStatement stmt, String sha1, String kind, String codec,
int width, int height, int frames, int fps, String author, String note)
throws Exception {
int width, int height, int frames, int fps, String author, String note,
String version) throws Exception {
stmt.setString(1, sha1);
stmt.setString(2, kind);
stmt.setString(3, codec);
@@ -238,7 +248,7 @@ public final class GraphicsIndexer {
stmt.setInt(7, fps);
stmt.setString(8, author);
stmt.setString(9, note);
stmt.setString(10, VERSION);
stmt.setString(10, version);
stmt.setString(11, Instant.now().toString());
stmt.executeUpdate();
}
@@ -262,13 +272,15 @@ public final class GraphicsIndexer {
LEFT JOIN media m ON m.sha1 = b.sha1
WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN')
""" + (force ? "" : """
AND (m.sha1 IS NULL OR m.tool_version <> ?)
AND (m.sha1 IS NULL OR m.tool_version <>
CASE b.format WHEN 'ANN' THEN ? ELSE ? END)
""") + "ORDER BY c.id";
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) {
st.setString(1, VERSION);
st.setString(1, ANIMATION_VERSION);
st.setString(2, IMAGE_VERSION);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
@@ -49,7 +49,7 @@ public final class PikGraphics {
/** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd,
List<Integer> frames) {
int repeatCount, int flags, List<Integer> frames) {
}
public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight,
@@ -144,9 +144,10 @@ public final class PikGraphics {
reader.skip(4);
int loopStart = reader.u16();
int loopEnd = reader.u16();
reader.skip(4); // liczba powtórzeń i licznik
int repeatCount = reader.u16();
reader.skip(2); // bieżący licznik powtórzeń
reader.skip(4);
reader.skip(4); // flagi
int flags = reader.i32();
reader.u8(); // krycie
reader.skip(12);
@@ -157,7 +158,7 @@ public final class PikGraphics {
for (int i = 0; i < framesCount; i++) {
skipFrameData(reader);
}
return new AnimEvent(name, framesCount, loopStart, loopEnd, frames);
return new AnimEvent(name, framesCount, loopStart, loopEnd, repeatCount, flags, frames);
}
/** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */
@@ -37,6 +37,9 @@ public final class WebServer {
private final MediaIndex media;
private final Transcriber transcriber;
private final Object lock;
private final Object animationCacheLock = new Object();
private String cachedAnimationSha1;
private PikGraphics.Animation cachedAnimation;
public WebServer(Database db, Path dataDir, Object lock) {
this.db = db;
@@ -139,6 +142,9 @@ public final class WebServer {
if (path.equals("/titles")) {
return titles();
}
if (path.equals("/assets")) {
return assets(query);
}
if (path.equals("/search")) {
return searchScripts(query.getOrDefault("q", ""),
parseInt(query.get("limit"), 30));
@@ -250,19 +256,17 @@ public final class WebServer {
private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException {
byte[] png;
try {
MediaIndex.Clip clip;
String kind;
synchronized (lock) {
clip = media.read(sha1);
kind = mediaKind(sha1);
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
java.awt.image.BufferedImage image;
if ("anim".equals(kind)) {
PikGraphics.Animation animation = PikGraphics.readAnimation(clip.bytes());
PikGraphics.Animation animation = animation(sha1);
if (animation == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
if (animation.frames().isEmpty()) {
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
return;
@@ -270,6 +274,14 @@ public final class WebServer {
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
image = animation.frames().get(index).image();
} else {
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
image = PikGraphics.readImage(clip.bytes()).image();
}
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
@@ -286,6 +298,31 @@ public final class WebServer {
exchange.getResponseBody().write(png);
}
/**
* Trzyma ostatnio oglądane ANN w pamięci. Odtwarzacz prosi o kilkanaście
* klatek na sekundę, a ponowne rozpakowywanie całego pliku dla każdej z nich
* byłoby znacznie droższe niż sam zapis PNG. Jedna pozycja ogranicza koszt
* pamięci także dla animacji z dużymi obrazami.
*/
private PikGraphics.Animation animation(String sha1) throws Exception {
synchronized (animationCacheLock) {
if (sha1.equals(cachedAnimationSha1)) {
return cachedAnimation;
}
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
return null;
}
PikGraphics.Animation decoded = PikGraphics.readAnimation(clip.bytes());
cachedAnimationSha1 = sha1;
cachedAnimation = decoded;
return decoded;
}
}
private String previewPath(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
@@ -555,6 +592,146 @@ public final class WebServer {
return out;
}
/**
* Paginowana lista zasobów do głównej galerii. Jedna treść może występować
* na kilku płytach, dlatego karta jest kluczowana SHA-1, a liczba miejsc
* pozostaje jawna. Wpisy archiwów SND są włączone do tej samej listy co
* zwykłe pliki — z punktu widzenia przeglądającego oba są nagraniami.
*/
private JsonObject assets(Map<String, String> query) throws Exception {
Integer edition = query.containsKey("edition")
? parseInt(query.get("edition"), -1) : null;
if (edition != null && edition < 0) {
edition = null;
}
String kind = query.getOrDefault("kind", "visual").toLowerCase(Locale.ROOT);
if (!List.of("visual", "image", "anim", "audio", "all").contains(kind)) {
kind = "visual";
}
String text = query.getOrDefault("q", "").trim().toLowerCase(Locale.ROOT);
int limit = Math.max(1, Math.min(parseInt(query.get("limit"), 80), 200));
int offset = Math.max(0, parseInt(query.get("offset"), 0));
String sort = query.getOrDefault("sort", "name");
String places = """
WITH places AS (
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(f.path_raw, f.path) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
0 AS in_archive
FROM media m
JOIN file f ON f.blob_sha1 = m.sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
UNION ALL
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(ae.name_raw, ae.name) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
1 AS in_archive
FROM media m
JOIN archive_entry ae ON ae.sha1 = m.sha1
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
)
""";
StringBuilder where = new StringBuilder(" WHERE 1 = 1\n");
List<Object> params = new ArrayList<>();
switch (kind) {
case "image", "anim", "audio" -> {
where.append(" AND kind = ?\n");
params.add(kind);
}
case "visual" -> where.append(" AND kind IN ('image', 'anim')\n");
default -> { }
}
if (edition != null) {
where.append(" AND edition_id = ?\n");
params.add(edition);
}
if (!text.isBlank()) {
where.append(" AND (lower(path) LIKE ? OR lower(COALESCE(title, '')) LIKE ? "
+ "OR lower(COALESCE(copy, '')) LIKE ? OR lower(COALESCE(codec, '')) LIKE ?)\n");
String like = "%" + text + "%";
params.add(like);
params.add(like);
params.add(like);
params.add(like);
}
long total;
try (PreparedStatement st = db.connection().prepareStatement(
places + "SELECT COUNT(DISTINCT sha1) FROM places" + where)) {
bind(st, params);
try (ResultSet rs = st.executeQuery()) {
total = rs.next() ? rs.getLong(1) : 0;
}
}
String order = switch (sort) {
case "game" -> "lower(COALESCE(MIN(title), '')), lower(MIN(path)), sha1";
case "largest" -> "COALESCE(MAX(width), 0) * COALESCE(MAX(height), 0) "
+ "DESC, lower(MIN(path)), sha1";
default -> "lower(MIN(path)), sha1";
};
String sql = places + """
SELECT sha1, MIN(kind) AS kind, MIN(codec) AS codec,
MAX(duration_ms) AS duration_ms, MAX(width) AS width,
MAX(height) AS height, MAX(frames) AS frames, MAX(fps) AS fps,
MIN(path) AS path, MIN(copy) AS copy, MIN(edition_id) AS edition_id,
MIN(title) AS title, MAX(in_archive) AS in_archive,
COUNT(*) AS places_count, COUNT(DISTINCT edition_id) AS games_count
FROM places
""" + where + " GROUP BY sha1 ORDER BY " + order + " LIMIT ? OFFSET ?";
JsonArray items = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
bind(st, pageParams);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("sha1", rs.getString("sha1"));
row.addProperty("kind", rs.getString("kind"));
row.addProperty("codec", rs.getString("codec"));
row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("width", rs.getInt("width"));
row.addProperty("height", rs.getInt("height"));
row.addProperty("frames", rs.getInt("frames"));
row.addProperty("fps", rs.getInt("fps"));
row.addProperty("path", rs.getString("path"));
row.addProperty("name", baseName(rs.getString("path")));
row.addProperty("copy", rs.getString("copy"));
row.addProperty("edition_id", rs.getInt("edition_id"));
row.addProperty("title", rs.getString("title"));
row.addProperty("in_archive", rs.getInt("in_archive") == 1);
row.addProperty("places_count", rs.getInt("places_count"));
row.addProperty("games_count", rs.getInt("games_count"));
items.add(row);
}
}
}
JsonObject out = new JsonObject();
out.addProperty("total", total);
out.addProperty("offset", offset);
out.addProperty("limit", limit);
out.addProperty("has_more", offset + items.size() < total);
out.add("items", items);
return out;
}
private JsonObject searchScripts(String query, int limit) throws Exception {
JsonObject out = new JsonObject();
if (query.isBlank()) {
@@ -871,7 +1048,7 @@ public final class WebServer {
JsonArray events = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT name, frames_count, loop_start, loop_end
SELECT name, frames_count, loop_start, loop_end, frames, repeat_count, flags
FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid
""")) {
st.setString(1, sha1);
@@ -882,6 +1059,16 @@ public final class WebServer {
row.addProperty("frames_count", rs.getInt("frames_count"));
row.addProperty("loop_start", rs.getInt("loop_start"));
row.addProperty("loop_end", rs.getInt("loop_end"));
row.addProperty("repeat_count", rs.getInt("repeat_count"));
row.addProperty("flags", rs.getInt("flags"));
JsonArray frames = new JsonArray();
String encoded = rs.getString("frames");
if (encoded != null && !encoded.isBlank()) {
for (String value : encoded.split(",")) {
frames.add(parseInt(value, 0));
}
}
row.add("frames", frames);
events.add(row);
}
}
@@ -900,6 +1087,21 @@ public final class WebServer {
return array;
}
private static void bind(PreparedStatement statement, List<Object> values)
throws Exception {
for (int i = 0; i < values.size(); i++) {
statement.setObject(i + 1, values.get(i));
}
}
private static String baseName(String path) {
if (path == null) {
return null;
}
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return slash >= 0 ? path.substring(slash + 1) : path;
}
private long scalar(String sql) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) {
+510 -59
View File
@@ -6,13 +6,16 @@
<title>rex-catalog</title>
<style>
:root {
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368;
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --panel: #fffefa; --panel-2: #ebe8e0; --line: #d9d4c9;
--ink: #292721; --dim: #736e64; --accent: #9b5c28; --accent-soft: #f2e3d1;
--hl: #f2e6d5; --shadow: 0 8px 30px rgba(55, 45, 31, .08);
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c;
--accent: #d3a05e; --hl: #3d3323;
--bg: #171613; --panel: #211f1a; --panel-2: #2a2721; --line: #3c3830;
--ink: #eee9df; --dim: #aaa294; --accent: #dfa45f; --accent-soft: #3a2c20;
--hl: #342b21; --shadow: 0 12px 34px rgba(0, 0, 0, .24);
}
}
* { box-sizing: border-box; }
@@ -21,19 +24,23 @@ body {
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap;
min-height: 62px; padding: 10px 18px; border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 94%, transparent);
display: flex; gap: 18px; align-items: center; position: sticky; top: 0; z-index: 8;
backdrop-filter: blur(14px);
}
h1 { margin: 0; font-size: 17px; letter-spacing: .02em; }
h1 { margin: 0; font-size: 18px; letter-spacing: -.02em; white-space: nowrap; }
h1 span { color: var(--accent); }
#stats { color: var(--dim); font-size: 12.5px; }
nav { margin-left: auto; display: flex; gap: 4px; }
#stats { color: var(--dim); font-size: 12px; white-space: nowrap; }
nav { margin-left: auto; display: flex; gap: 3px; padding: 3px; border-radius: 9px;
background: var(--panel-2); }
nav button {
border: 1px solid var(--line); background: transparent; color: var(--ink);
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px;
border: 0; background: transparent; color: var(--dim);
padding: 7px 12px; border-radius: 7px; cursor: pointer; font-size: 13px;
}
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
main { padding: 18px 20px; max-width: 1500px; }
nav button:hover { color: var(--ink); }
nav button.on { background: var(--panel); color: var(--ink); box-shadow: 0 1px 5px rgba(0,0,0,.12); }
main { padding: 16px 18px; max-width: 1800px; margin: 0 auto; }
/* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja
z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */
[hidden] { display: none !important; }
@@ -118,6 +125,129 @@ button.akcja { border: 1px solid var(--line); background: var(--panel); color: v
padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; }
button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.akcja:disabled { opacity: .45; cursor: default; }
/* Główna przeglądarka zasobów. Trzy kolumny zachowują kontekst: wybór kolekcji,
wyniki i szczegóły zaznaczonego elementu są widoczne jednocześnie. */
.asset-layout { display: grid; grid-template-columns: 218px minmax(440px, 1fr) 330px;
gap: 14px; min-height: calc(100vh - 95px); align-items: stretch; }
.asset-sidebar, .asset-browser, .asset-inspector {
background: var(--panel); border: 1px solid var(--line); border-radius: 11px;
min-width: 0;
}
.asset-sidebar { padding: 14px 10px; }
.side-heading { margin: 4px 8px 7px; color: var(--dim); font-size: 10.5px;
font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.side-heading:not(:first-child) { margin-top: 22px; }
.side-button { display: flex; width: 100%; align-items: center; gap: 8px; border: 0;
padding: 7px 9px; border-radius: 7px; background: transparent; color: var(--ink);
cursor: pointer; text-align: left; font: inherit; font-size: 12.5px; }
.side-button:hover { background: var(--panel-2); }
.side-button.on { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
.side-button .count { margin-left: auto; color: var(--dim); font-size: 11px;
font-variant-numeric: tabular-nums; }
.kind-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--line); flex: 0 0 auto; }
.side-button.on .kind-dot { background: var(--accent); }
.game-buttons { max-height: calc(100vh - 390px); overflow-y: auto; scrollbar-width: thin; }
.asset-browser { display: flex; flex-direction: column; overflow: hidden; }
.asset-toolbar { padding: 13px 14px; border-bottom: 1px solid var(--line); }
.asset-title-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.asset-title-row h2 { font-size: 16px; margin: 0; }
.asset-title-row .result-count { color: var(--dim); font-size: 12px; }
.asset-controls { display: grid; grid-template-columns: minmax(180px, 1fr) 135px; gap: 8px; }
.search-wrap { position: relative; }
.search-wrap::before { content: '⌕'; color: var(--dim); position: absolute; left: 11px; top: 5px;
font-size: 19px; pointer-events: none; }
.search-wrap input { padding-left: 34px; background: var(--bg); }
.asset-content { flex: 1; padding: 14px; overflow: auto; }
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 11px; align-content: start; }
.asset-card { display: block; padding: 0; overflow: hidden; border: 1px solid var(--line);
border-radius: 9px; background: var(--bg); color: var(--ink); cursor: pointer;
text-align: left; min-width: 0; font: inherit; transition: border-color .12s, transform .12s,
box-shadow .12s; }
.asset-card:hover { border-color: color-mix(in srgb, var(--accent) 68%, var(--line));
transform: translateY(-1px); box-shadow: var(--shadow); }
.asset-card.on { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.asset-thumb { height: 122px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); overflow: hidden; }
.asset-thumb img { width: 100%; height: 100%; display: block; object-fit: contain; }
.asset-audio-art { width: 58px; height: 58px; border-radius: 50%; display: grid; place-items: center;
color: var(--accent); background: var(--accent-soft); font-size: 25px; }
.asset-card-body { padding: 8px 9px 9px; }
.asset-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--mono); font-size: 11.5px; font-weight: 650; }
.asset-game { color: var(--dim); font-size: 10.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.asset-badges { display: flex; gap: 4px; align-items: center; margin-top: 6px; }
.asset-badge { padding: 1px 5px; border-radius: 4px; background: var(--panel-2);
color: var(--dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .03em; }
.asset-badge.frames { color: var(--accent); background: var(--accent-soft); }
.asset-more { padding: 18px 0 5px; text-align: center; }
.skeleton { min-height: 180px; border-radius: 9px; background: linear-gradient(90deg,
var(--panel-2), var(--bg), var(--panel-2)); background-size: 200% 100%;
animation: shimmer 1.2s infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
.asset-inspector { overflow: auto; position: sticky; top: 78px; max-height: calc(100vh - 95px); }
.inspector-empty { height: 100%; min-height: 340px; display: grid; place-items: center;
padding: 28px; color: var(--dim); text-align: center; }
.inspector-hero { min-height: 235px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); border-bottom: 1px solid var(--line); overflow: hidden;
border-radius: 10px 10px 0 0; }
.inspector-hero img { display: block; max-width: 100%; max-height: 330px; object-fit: contain; }
.inspector-body { padding: 15px; }
.inspector-body h2 { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere;
margin-bottom: 4px; }
.inspector-sub { color: var(--dim); font-size: 11.5px; margin-bottom: 14px; }
.inspector-actions { display: flex; gap: 7px; margin-bottom: 14px; }
.inspector-section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.inspector-section h3 { margin-top: 0; }
.path-list { font-size: 10.5px; font-family: var(--mono); color: var(--dim);
overflow-wrap: anywhere; }
.inspector-player { width: calc(100% - 24px); margin: 12px; }
.anim-player { min-width: 0; }
.anim-stage { min-height: 235px; max-height: 58vh; display: flex; align-items: center;
justify-content: center; background-color: var(--panel-2); overflow: hidden; }
.anim-stage img { display: block; max-width: 100%; max-height: 58vh; object-fit: contain;
image-rendering: auto; }
.anim-toolbar { display: flex; align-items: center; gap: 7px; padding: 9px 10px;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--panel); }
.anim-toolbar .frame-slider { flex: 1; min-width: 60px; }
.anim-toolbar select { width: auto; padding: 5px 7px; font-size: 11px; }
.anim-play { width: 31px; height: 29px; padding: 0 !important; font-size: 15px !important; }
.anim-counter { min-width: 76px; color: var(--dim); font: 10.5px var(--mono);
text-align: right; white-space: nowrap; }
.anim-loop { display: flex; align-items: center; gap: 4px; color: var(--dim);
font-size: 10.5px; white-space: nowrap; cursor: pointer; }
.anim-events { padding: 12px 14px 14px; }
.anim-event-list { display: flex; flex-wrap: wrap; gap: 5px; max-height: 180px;
overflow: auto; scrollbar-width: thin; }
.anim-event { border: 1px solid var(--line); border-radius: 6px; padding: 4px 7px;
background: var(--bg); color: var(--ink); cursor: pointer; font: 10.5px var(--mono); }
.anim-event:hover { border-color: var(--accent); }
.anim-event.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.anim-event small { color: var(--dim); font: 9px system-ui, sans-serif; }
.anim-event-info { min-height: 18px; margin-top: 7px; color: var(--dim); font-size: 10.5px; }
.asset-inspector .anim-stage { border-radius: 10px 10px 0 0; }
.asset-inspector .anim-events { border-bottom: 1px solid var(--line); }
@media (max-width: 1180px) {
.asset-layout { grid-template-columns: 190px minmax(400px, 1fr) 290px; }
.asset-grid { grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)); }
}
@media (max-width: 900px) {
header { flex-wrap: wrap; }
#stats { display: none; }
nav { order: 3; width: 100%; overflow-x: auto; margin: 0; }
main { padding: 10px; }
.asset-layout { grid-template-columns: 1fr; }
.asset-sidebar { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.side-heading { grid-column: 1 / -1; }
.game-buttons { grid-column: 1 / -1; max-height: 150px; }
.asset-inspector { position: static; max-height: none; }
}
</style>
</head>
<body>
@@ -126,15 +256,66 @@ button.akcja:disabled { opacity: .45; cursor: default; }
<h1>rex<span>·</span>catalog</h1>
<div id="stats">wczytuję…</div>
<nav>
<button data-view="katalog" class="on">Katalog</button>
<button data-view="zasoby" class="on">Zasoby</button>
<button data-view="skrypty">Skrypty</button>
<button data-view="dzwiek">Dźwięk</button>
<button data-view="formaty">Formaty</button>
<button data-view="katalog">Kolekcja</button>
<button data-view="formaty">Statystyki</button>
</nav>
</header>
<main>
<section id="katalog" class="cols">
<section id="zasoby" class="asset-layout">
<aside class="asset-sidebar">
<div class="side-heading">Rodzaj zasobu</div>
<button class="side-button on" data-kind="visual"><span class="kind-dot"></span>
Grafika <span class="count" id="licz-grafika"></span></button>
<button class="side-button" data-kind="image"><span class="kind-dot"></span>
Obrazy <span class="count" id="licz-obrazy"></span></button>
<button class="side-button" data-kind="anim"><span class="kind-dot"></span>
Animacje <span class="count" id="licz-animacje"></span></button>
<button class="side-button" data-kind="audio"><span class="kind-dot"></span>
Nagrania <span class="count" id="licz-audio"></span></button>
<div class="side-heading">Gra</div>
<div class="game-buttons" id="gryzasobow">
<button class="side-button on" data-edition=""><span>Wszystkie gry</span></button>
</div>
</aside>
<div class="asset-browser">
<div class="asset-toolbar">
<div class="asset-title-row">
<h2 id="tytulzasobow">Wszystkie zasoby</h2>
<span class="result-count" id="licznikzasobow">wczytuję…</span>
</div>
<div class="asset-controls">
<div class="search-wrap">
<input type="search" id="szukajzasobow" placeholder="Szukaj po nazwie lub ścieżce…"
autocomplete="off">
</div>
<select id="sortzasobow" aria-label="Sortowanie zasobów">
<option value="name">według nazwy</option>
<option value="game">według gry</option>
<option value="largest">największe</option>
</select>
</div>
</div>
<div class="asset-content" id="przewijakzasobow">
<div class="asset-grid" id="siatkazasobow"></div>
<div class="asset-more" id="wiecejzasobow"></div>
</div>
</div>
<aside class="asset-inspector" id="inspektorzasobu">
<div class="inspector-empty">
<div><div style="font-size:30px;margin-bottom:8px"></div>
Wybierz zasób, żeby zobaczyć podgląd, parametry i miejsca użycia.</div>
</div>
</aside>
</section>
<section id="katalog" hidden class="cols">
<div>
<div class="card">
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
@@ -185,8 +366,10 @@ button.akcja:disabled { opacity: .45; cursor: default; }
</section>
<section id="formaty" hidden>
<div class="card" style="max-width:620px">
<h2>Rozkład formatów</h2>
<div class="card" style="max-width:720px">
<h2>Statystyki kolekcji</h2>
<div id="pelneStatystyki" class="dim" style="font-size:12.5px;margin:7px 0 18px"></div>
<h3>Rozkład formatów</h3>
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
<tbody id="tabformaty"></tbody></table>
</div>
@@ -210,10 +393,291 @@ let tytuly = [];
// ---- nawigacja ----
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
if (b.dataset.view !== 'zasoby') zatrzymajWszystkieAnimacje();
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
['katalog','skrypty','dzwiek','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
['zasoby','katalog','skrypty','dzwiek','formaty'].forEach(v =>
$('#'+v).hidden = v !== b.dataset.view);
});
// ---- zasoby ----
const stanZasobow = {
kind: 'visual', edition: null, q: '', sort: 'name', offset: 0, total: 0,
loading: false, selected: null, items: new Map()
};
const nazwyRodzajow = {
visual: 'Grafika', image: 'Obrazy', anim: 'Animacje', audio: 'Nagrania'
};
let czekajZasoby;
function rysujGryZasobow() {
$('#gryzasobow').innerHTML = `
<button class="side-button ${stanZasobow.edition == null ? 'on' : ''}" data-edition="">
<span>Wszystkie gry</span></button>` +
tytuly.map(t => `<button class="side-button ${stanZasobow.edition == t.edition_id ? 'on' : ''}"
data-edition="${t.edition_id}" title="${esc(t.title)}">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
</button>`).join('');
$('#gryzasobow').querySelectorAll('[data-edition]').forEach(b => b.onclick = () => {
stanZasobow.edition = b.dataset.edition ? +b.dataset.edition : null;
$('#gryzasobow').querySelectorAll('.side-button').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
}
function aktualizujTytulZasobow() {
const gra = tytuly.find(t => t.edition_id === stanZasobow.edition);
$('#tytulzasobow').textContent = gra ? gra.title : nazwyRodzajow[stanZasobow.kind];
}
function opisKarty(a) {
if (a.kind === 'audio') return czas(a.duration_ms);
if (a.kind === 'anim') return `${a.frames || 1} kl. · ${a.width || '?'}×${a.height || '?'}`;
return `${a.width || '?'}×${a.height || '?'}`;
}
const aktywneOdtwarzacze = new Set();
function htmlOdtwarzaczaAnimacji(m, sha1, pokazZdarzenia = true) {
return `<div class="anim-player" data-animation="${sha1}">
<div class="anim-stage kratka"><img src="/api/render/${sha1}?frame=0" alt=""></div>
<div class="anim-toolbar">
<button class="akcja anim-play" data-anim-play title="Odtwórz lub zatrzymaj">▶</button>
<input class="frame-slider" data-anim-slider type="range" min="0"
max="${Math.max(0, m.frames - 1)}" value="0" aria-label="Klatka animacji">
<span class="anim-counter" data-anim-counter>1 / ${m.frames}</span>
<select data-anim-speed title="Prędkość odtwarzania">
<option value="0.5">½×</option><option value="1" selected>1×</option>
<option value="2">2×</option>
</select>
<label class="anim-loop"><input type="checkbox" data-anim-loop checked> pętla</label>
</div>
${pokazZdarzenia && m.events?.length ? `<div class="anim-events">
<h3 style="margin-top:0">Zdarzenia (${m.events.length})</h3>
<div class="anim-event-list">
<button class="anim-event on" data-anim-event="">Wszystkie klatki</button>
${m.events.map((e, i) => `<button class="anim-event" data-anim-event="${i}">
${esc(e.name)} <small>${e.frames_count}</small></button>`).join('')}
</div>
<div class="anim-event-info" data-anim-event-info>
Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.
</div>
</div>` : ''}
</div>`;
}
function zatrzymajOdtwarzacz(root) {
const controller = root?._animController;
if (controller) controller.stop();
}
function podlaczOdtwarzacz(root, m, sha1) {
if (!root) return;
zatrzymajOdtwarzacz(root);
const image = root.querySelector('.anim-stage img');
const play = root.querySelector('[data-anim-play]');
const slider = root.querySelector('[data-anim-slider]');
const counter = root.querySelector('[data-anim-counter]');
const speed = root.querySelector('[data-anim-speed]');
const loop = root.querySelector('[data-anim-loop]');
const eventInfo = root.querySelector('[data-anim-event-info]');
let sequence = Array.from({length: m.frames}, (_, i) => i);
let event = null, position = 0, playing = false, timer = null;
const controller = {
stop() {
playing = false;
clearTimeout(timer);
play.textContent = '▶';
aktywneOdtwarzacze.delete(controller);
}
};
root._animController = controller;
const pokaz = () => {
if (!sequence.length) return;
position = Math.max(0, Math.min(position, sequence.length - 1));
const frame = Math.max(0, Math.min(sequence[position], m.frames - 1));
image.src = `/api/render/${sha1}?frame=${frame}`;
slider.max = Math.max(0, sequence.length - 1);
slider.value = position;
counter.textContent = `${position + 1} / ${sequence.length} · #${frame + 1}`;
};
const nastepna = () => {
if (!playing) return;
let next = position + 1;
if (loop.checked && event && event.loop_end > 0 && next > event.loop_end) {
next = Math.max(0, Math.min(event.loop_start, sequence.length - 1));
} else if (next >= sequence.length) {
if (loop.checked) next = 0;
else { controller.stop(); return; }
}
position = next;
pokaz();
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
const start = () => {
if (playing || sequence.length < 2) return;
if (position >= sequence.length - 1 && !loop.checked) position = 0;
playing = true;
play.textContent = '❚❚';
aktywneOdtwarzacze.add(controller);
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
play.onclick = () => playing ? controller.stop() : start();
slider.oninput = () => { position = +slider.value; pokaz(); };
root.querySelectorAll('[data-anim-event]').forEach(button => button.onclick = () => {
controller.stop();
root.querySelectorAll('[data-anim-event]').forEach(x => x.classList.toggle('on', x === button));
event = button.dataset.animEvent === '' ? null : m.events[+button.dataset.animEvent];
sequence = event?.frames?.length
? event.frames.slice()
: event
? Array.from({length: Math.min(event.frames_count, m.frames)}, (_, i) => i)
: Array.from({length: m.frames}, (_, i) => i);
position = 0;
loop.checked = !event || event.loop_end > 0;
if (eventInfo) eventInfo.innerHTML = event
? `<span class="mono">${esc(event.name)}</span> · ${event.frames_count} kroków`
+ (event.loop_end > 0
? ` · pętla ${event.loop_start + 1}${event.loop_end + 1}`
: ' · bez pętli')
: `Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.`;
pokaz();
start();
});
pokaz();
}
function zatrzymajWszystkieAnimacje() {
[...aktywneOdtwarzacze].forEach(controller => controller.stop());
}
function kartaZasobu(a) {
const podglad = a.kind === 'audio'
? '<div class="asset-audio-art" aria-hidden="true">♪</div>'
: `<img class="kratka" loading="lazy" src="/api/preview/${a.sha1}" alt="">`;
return `<button class="asset-card ${stanZasobow.selected === a.sha1 ? 'on' : ''}"
data-asset="${a.sha1}" title="${esc(a.path)}">
<div class="asset-thumb">${podglad}</div>
<div class="asset-card-body">
<div class="asset-name">${esc(a.name || a.path)}</div>
<div class="asset-game">${esc(a.title || a.copy || 'bez przypisanej gry')}</div>
<div class="asset-badges">
<span class="asset-badge">${esc(a.codec || a.kind)}</span>
<span class="asset-badge ${a.kind === 'anim' ? 'frames' : ''}">${opisKarty(a)}</span>
${a.places_count > 1 ? `<span class="asset-badge" title="${a.places_count} wystąpień">×${a.places_count}</span>` : ''}
</div>
</div>
</button>`;
}
async function ladujZasoby(reset) {
if (stanZasobow.loading) return;
stanZasobow.loading = true;
if (reset) {
stanZasobow.offset = 0;
stanZasobow.items.clear();
$('#siatkazasobow').innerHTML = Array.from({length: 8}, () => '<div class="skeleton"></div>').join('');
$('#wiecejzasobow').innerHTML = '';
$('#licznikzasobow').textContent = 'wczytuję…';
}
try {
const p = new URLSearchParams({kind: stanZasobow.kind, q: stanZasobow.q,
sort: stanZasobow.sort, offset: stanZasobow.offset, limit: 80});
if (stanZasobow.edition != null) p.set('edition', stanZasobow.edition);
const r = await get('/api/assets?' + p);
if (reset) $('#siatkazasobow').innerHTML = '';
r.items.forEach(a => stanZasobow.items.set(a.sha1, a));
$('#siatkazasobow').insertAdjacentHTML('beforeend', r.items.map(kartaZasobu).join(''));
$('#siatkazasobow').querySelectorAll('[data-asset]').forEach(k =>
k.onclick = () => pokazZasobWInspektor(k.dataset.asset));
stanZasobow.offset += r.items.length;
stanZasobow.total = r.total;
$('#licznikzasobow').textContent = `${r.total.toLocaleString('pl')} zasobów`;
if (!r.items.length && reset) {
$('#siatkazasobow').innerHTML = '<div class="empty" style="grid-column:1/-1">Nic nie pasuje do filtrów.</div>';
}
$('#wiecejzasobow').innerHTML = r.has_more
? '<button class="akcja" id="wiecej">Pokaż więcej</button>' : '';
if ($('#wiecej')) $('#wiecej').onclick = () => ladujZasoby(false);
} catch (e) {
$('#siatkazasobow').innerHTML = `<div class="empty" style="grid-column:1/-1">Błąd: ${esc(e.message)}</div>`;
} finally {
stanZasobow.loading = false;
}
}
async function pokazZasobWInspektor(sha1) {
zatrzymajOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'));
stanZasobow.selected = sha1;
$('#siatkazasobow').querySelectorAll('.asset-card').forEach(k =>
k.classList.toggle('on', k.dataset.asset === sha1));
const a = stanZasobow.items.get(sha1) || {};
$('#inspektorzasobu').innerHTML = '<div class="inspector-empty">Wczytuję szczegóły…</div>';
try {
const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim', audio = m.kind === 'audio';
const hero = audio
? `<audio class="inspector-player" controls autoplay preload="metadata" src="/api/audio/${sha1}"></audio>`
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
: `<div class="inspector-hero kratka"><img src="/api/render/${sha1}" alt=""></div>`;
const d = m.dialogue?.[0] || {};
$('#inspektorzasobu').innerHTML = `${hero}<div class="inspector-body">
<h2>${esc(a.name || m.places?.[0]?.path || sha1)}</h2>
<div class="inspector-sub">${esc(a.title || a.copy || '')}</div>
${!audio ? `<div class="inspector-actions">
<button class="akcja" id="pelnyPodglad">Pełny podgląd</button>
</div>` : ''}
<dl>
<dt>Rodzaj</dt><dd>${m.kind === 'anim' ? 'animacja' : m.kind === 'image' ? 'obraz' : 'nagranie'}</dd>
<dt>Format</dt><dd>${esc(m.codec || '—')}</dd>
${audio ? `<dt>Długość</dt><dd>${czas(m.duration_ms)}</dd>
<dt>Dźwięk</dt><dd>${m.sample_rate || '—'} Hz · ${m.channels === 1 ? 'mono' : m.channels + ' kanały'}</dd>`
: `<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>`}
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
${d.speaker ? `<dt>Mówi</dt><dd>${esc(d.speaker)}</dd>` : ''}
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
</dl>
${m.transcript ? `<div class="inspector-section"><h3>Co słychać</h3>
<div>${esc(m.transcript)}</div><div class="maszyna">rozpoznane maszynowo</div></div>` : ''}
${m.used_by?.length ? `<div class="inspector-section"><h3>Używane w skryptach (${m.used_by.length})</h3>
${m.used_by.slice(0, 10).map(u => `<div style="padding:2px 0;font-size:12px">
<a href="#" data-skrypt="${u.script_sha1}" class="mono">${esc(u.object)}</a>
<span class="dim">:${esc(u.field)}</span></div>`).join('')}</div>` : ''}
<div class="inspector-section"><h3>Miejsca (${m.places?.length || 0})</h3>
<div class="path-list">${(m.places || []).slice(0, 12).map(p =>
`${esc(p.path)}<br><span class="dim">${esc(p.copy)}</span>`).join('<br><br>')}</div></div>
<div class="inspector-section"><div class="mono dim" style="font-size:9.5px">sha1 ${esc(sha1)}</div></div>
</div>`;
if ($('#pelnyPodglad')) $('#pelnyPodglad').onclick = () => otworzZasob(sha1);
if (anim) podlaczOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'), m, sha1);
$('#inspektorzasobu').querySelectorAll('[data-skrypt]').forEach(link =>
link.onclick = e => { e.preventDefault(); otworzSkrypt(link.dataset.skrypt); });
} catch (e) {
$('#inspektorzasobu').innerHTML = `<div class="inspector-empty">Nie udało się wczytać zasobu:<br>${esc(e.message)}</div>`;
}
}
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => {
stanZasobow.kind = b.dataset.kind;
document.querySelectorAll('[data-kind]').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
$('#szukajzasobow').oninput = e => {
clearTimeout(czekajZasoby);
stanZasobow.q = e.target.value.trim();
czekajZasoby = setTimeout(() => ladujZasoby(true), 250);
};
$('#sortzasobow').onchange = e => { stanZasobow.sort = e.target.value; ladujZasoby(true); };
// ---- katalog ----
function rysujTytuly(filtr = '') {
const f = filtr.toLowerCase();
@@ -529,12 +993,9 @@ async function otworzNagranie(sha1) {
}
// ---- podgląd grafiki ----
let klatki = null;
async function otworzZasob(sha1) {
const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim';
klatki = anim ? {sha1, ile: m.frames, teraz: 0} : null;
$('#zaslona').innerHTML = `<div>
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
@@ -544,13 +1005,8 @@ async function otworzZasob(sha1) {
</div>
${m.note && m.note.startsWith('nieczytelny')
? `<div class="empty">${esc(m.note)}</div>`
: `<img class="duzy kratka" src="/api/render/${sha1}${anim ? '?frame=0' : ''}" alt="">`}
${anim && m.frames > 1 ? `<div class="postep" style="margin-top:12px">
<button class="akcja" id="poprz"></button>
<input type="range" id="suwak" min="0" max="${m.frames - 1}" value="0" style="flex:1">
<button class="akcja" id="nast"></button>
<span class="mono" style="font-size:12px" id="numer">1 / ${m.frames}</span>
</div>` : ''}
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
: `<img class="duzy kratka" src="/api/render/${sha1}" alt="">`}
<dl style="margin-top:12px">
<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
@@ -559,12 +1015,6 @@ async function otworzZasob(sha1) {
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
</dl>
${m.events && m.events.length ? `<h3>Zdarzenia (${m.events.length})</h3>
<div style="columns:3;column-gap:18px;font-size:12.5px">
${m.events.map(e => `<div style="break-inside:avoid;padding:1px 0">
<span class="mono">${esc(e.name)}</span>
<span class="dim">${e.frames_count} kl.</span></div>`).join('')}
</div>` : ''}
${m.used_by && m.used_by.length ? `<h3>Używane przez ${m.used_by.length} obiektów</h3>
<div class="dim" style="font-size:12px">${m.used_by.slice(0, 10)
.map(u => esc(u.object)).join(', ')}</div>` : ''}
@@ -573,34 +1023,27 @@ async function otworzZasob(sha1) {
$('#zamknij').onclick = zamknijZaslone;
$('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); };
const suwak = $('#suwak');
if (suwak) {
const pokaz = n => {
klatki.teraz = Math.max(0, Math.min(n, klatki.ile - 1));
suwak.value = klatki.teraz;
$('#numer').textContent = (klatki.teraz + 1) + ' / ' + klatki.ile;
$('#zaslona').querySelector('img.duzy').src =
`/api/render/${klatki.sha1}?frame=${klatki.teraz}`;
};
suwak.oninput = () => pokaz(+suwak.value);
$('#poprz').onclick = () => pokaz(klatki.teraz - 1);
$('#nast').onclick = () => pokaz(klatki.teraz + 1);
}
if (anim) podlaczOdtwarzacz($('#zaslona').querySelector('.anim-player'), m, sha1);
}
function zamknijZaslone() {
zatrzymajOdtwarzacz($('#zaslona').querySelector('.anim-player'));
$('#zaslona').hidden = true;
$('#zaslona').innerHTML = '';
klatki = null;
}
document.addEventListener('keydown', e => {
if ($('#zaslona').hidden) return;
if (e.key === 'Escape') zamknijZaslone();
if (klatki && $('#suwak')) {
if (e.key === 'ArrowLeft') $('#poprz').click();
if (e.key === 'ArrowRight') $('#nast').click();
const slider = $('#zaslona').querySelector('[data-anim-slider]');
if (slider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
slider.value = Math.max(0, Math.min(+slider.max,
+slider.value + (e.key === 'ArrowLeft' ? -1 : 1)));
slider.dispatchEvent(new Event('input'));
}
if (slider && e.key === ' ' && !['INPUT','SELECT','BUTTON'].includes(e.target.tagName)) {
e.preventDefault();
$('#zaslona').querySelector('[data-anim-play]').click();
}
});
@@ -663,12 +1106,17 @@ function sledzPostep() {
(async () => {
const s = await get('/api/stats');
$('#stats').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) `
+ `· ${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
+ `(${s.anim_frames.toLocaleString('pl')} klatek) `
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
`${s.files.toLocaleString('pl')} plików · ${s.copies} kopii · ${mb(s.bytes)}`;
$('#licz-grafika').textContent = (s.images + s.animations).toLocaleString('pl');
$('#licz-obrazy').textContent = s.images.toLocaleString('pl');
$('#licz-animacje').textContent = s.animations.toLocaleString('pl');
$('#licz-audio').textContent = s.audio.toLocaleString('pl');
$('#pelneStatystyki').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików · `
+ `${s.blobs.toLocaleString('pl')} unikalnych treści · ${s.scripts.toLocaleString('pl')} skryptów · `
+ `${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) · `
+ `${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
+ `(${s.anim_frames.toLocaleString('pl')} klatek) · ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n));
$('#tabformaty').innerHTML = s.formats.map(f => `
@@ -683,6 +1131,9 @@ function sledzPostep() {
tytuly = await get('/api/titles');
rysujTytuly();
rysujGryZasobow();
aktualizujTytulZasobow();
await ladujZasoby(true);
$('#filtr').oninput = e => rysujTytuly(e.target.value);
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
</script>