Compare commits
2
Commits
20ce26364c
...
8a870a60bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a870a60bb | ||
|
|
c2036e1f72 |
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user