Decode IMG and ANN graphics, and follow the engine's file lookup rules

10 459 images and animations were listed but unviewable. They are now decoded to
PNG without OpenGL, so a script reference like IMGOVERLAY:FILENAME=NAKLADKA.IMG
shows the actual picture.

Decompression comes from :core (CLZW2Compression, CRLECompression) — that is the
hard part and there is no reason to have two copies of it. The header parsing had
to be written here, and not by choice: ImageLoader keeps its parser private, and
AnimoLoader, despite a signature that looks headless-friendly, builds Image
objects whose constructor creates a Texture straight away. Field layout mirrors
those loaders one for one, so catalogue and emulator read the same bytes the same
way. Pixels are RGB565/RGB555 plus a separate alpha byte, composed with ImageIO.

One quirk needed care: ImageLoader maps compression 4 to "none" for IMG files,
but in animation frames the same 4 means real CRLE and AnimoLoader passes it
through. Applying the IMG quirk to ANN turned whole animations into noise.

3531 images and 6928 animations decode (81 515 frames, 29 019 named events);
30 files fail and are recorded with the reason. Previews are thumbnails only —
340 MB of cache instead of decoding everything to disk — and full frames are
rendered from the disc on demand. Animations carry their author: 6513 of them
are signed Piotr Maciejewski.

File lookup now follows the engine instead of guessing. A bare FILENAME means
next to the script; $ is the game root, so $COMMON\X and $WAVS\X resolve there;
WAV files live in wavs/. This matters because a name alone does not identify a
file — Wojna Trojańska ships seventeen different bkg.img, one per scene, and
matching on the name showed the wrong picture for all of them. Resolution is now
98% overall and 97% for WAV with nothing uncertain; the 900 matches that still
fall back to name-only are flagged in the UI rather than passed off as fact.
What stays unresolved is mostly save-state written at runtime.

Two extraction bugs fixed along the way: fields ending in ^N (VARIWST:ONCHANGED^2)
were skipped entirely, hiding 176 real references; and blocking a match at an
underscore made the regex restart one character later, cutting HIST0.ARR out of
+"_HIST0.ARR". Matches must now begin at a token boundary.

Resolution used to run as correlated subqueries over a CTE, re-evaluated per row:
36 s for a script with 745 references. Parameters are now bound directly and
file.basename is a generated, indexed column — 0.19 s.

Docker gains whisper.cpp and ffmpeg (tens of MB); the model is mounted under
/models instead, since it is the part that weighs gigabytes and everyone keeps a
different one. Not verified: the Docker daemon is not running on this machine, so
the image was not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-21 14:40:13 +02:00
co-authored by Claude Opus 5
parent 6feb2254b2
commit f1b36f0e29
12 changed files with 1214 additions and 71 deletions
+25 -4
View File
@@ -15,15 +15,36 @@ COPY src ./src
RUN ./gradlew --no-daemon installDist \ RUN ./gradlew --no-daemon installDist \
&& grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version && grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version
# whisper.cpp budowany ze źródeł: w repozytoriach Debiana go nie ma, a binarka
# z hosta jest nie do użycia — tam macOS, tu Linux
FROM debian:bookworm-slim AS whisper
RUN apt-get update \
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
&& git clone --depth 1 --branch v1.7.4 https://github.com/ggerganov/whisper.cpp /src \
&& cmake -S /src -B /src/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
&& cmake --build /src/build --target whisper-cli -j "$(nproc)" \
&& cmake --install /src/build \
&& rm -rf /var/lib/apt/lists/* /src
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
WORKDIR /app WORKDIR /app
# obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen # obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen
VOLUME ["/data"] VOLUME ["/data"]
# Transkrypcji mowy w obrazie nie ma i nie będzie: whisper.cpp plus model to
# kilka gigabajtów, a katalog jest w pełni użyteczny bez nich — mówcę i kontekst # whisper.cpp i ffmpeg wchodzą do obrazu, bo to kilkadziesiąt megabajtów.
# kwestii bierzemy z dialogi.dta. Front powie wprost, że narzędzia brakuje. # Model NIE wchodzi: waży kilkaset megabajtów do kilku gigabajtów, każdy trzyma
# Kto chce transkrybować, robi to na hoście: rex-catalog transcribe. # inny i wybór należy do użytkownika — montuje się go pod /models i wskazuje
# przez WHISPER_MODEL. Bez modelu katalog działa normalnie, a front mówi wprost,
# czego brakuje: mówcę i kontekst kwestii bierzemy z dialogi.dta, nie z modelu.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=whisper /usr/local/bin/whisper-cli /usr/local/bin/whisper-cli
COPY --from=whisper /usr/local/lib/libwhisper.so* /usr/local/lib/
COPY --from=whisper /usr/local/lib/libggml*.so /usr/local/lib/
RUN ldconfig
ENV WHISPER_BIN=/usr/local/bin/whisper-cli
# file.encoding ustawia entrypoint przez JAVA_OPTS; JAVA_TOOL_OPTIONS robiłoby # file.encoding ustawia entrypoint przez JAVA_OPTS; JAVA_TOOL_OPTIONS robiłoby
# to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu # to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu
ENV CATALOG_DATA=/data ENV CATALOG_DATA=/data
+7 -1
View File
@@ -9,8 +9,14 @@ services:
# kolekcja tylko do odczytu: katalog niczego w niej nie zmienia, # kolekcja tylko do odczytu: katalog niczego w niej nie zmienia,
# a obrazy płyt są nieodtwarzalne # a obrazy płyt są nieodtwarzalne
- ${COLLECTION:-./collection}:/media:ro - ${COLLECTION:-./collection}:/media:ro
# baza i cache odszyfrowanych skryptów — jedyny stan wart trzymania # baza oraz cache skryptów i miniatur — jedyny stan wart trzymania
- catalog-data:/data - catalog-data:/data
# modele whisper.cpp: nie wchodzą do obrazu, bo ważą więcej niż on sam
# i każdy trzyma inny. Bez nich katalog działa, tylko bez transkrypcji.
- ${WHISPER_MODELS:-./models}:/models:ro
environment:
# wskaż konkretny plik modelu, np. ggml-large-v3.bin z podmontowanego katalogu
WHISPER_MODEL: ${WHISPER_MODEL:-/models/ggml-large-v3.bin}
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+13 -3
View File
@@ -8,6 +8,7 @@ import pl.genschu.rexcatalog.ingest.LanguageDetector;
import pl.genschu.rexcatalog.ingest.MetadataDetector; import pl.genschu.rexcatalog.ingest.MetadataDetector;
import pl.genschu.rexcatalog.media.AudioIndexer; import pl.genschu.rexcatalog.media.AudioIndexer;
import pl.genschu.rexcatalog.media.DialogueIndexer; import pl.genschu.rexcatalog.media.DialogueIndexer;
import pl.genschu.rexcatalog.media.GraphicsIndexer;
import pl.genschu.rexcatalog.script.RefIndexer; import pl.genschu.rexcatalog.script.RefIndexer;
import pl.genschu.rexcatalog.transcribe.Transcriber; import pl.genschu.rexcatalog.transcribe.Transcriber;
import pl.genschu.rexcatalog.mcp.McpServer; import pl.genschu.rexcatalog.mcp.McpServer;
@@ -38,6 +39,8 @@ public final class Main {
System.getProperty("catalog.coreVersion", "nieznana"); System.getProperty("catalog.coreVersion", "nieznana");
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
// dekodowanie grafiki idzie przez AWT; bez tego JVM próbowałby otworzyć ekran
System.setProperty("java.awt.headless", "true");
if (args.length == 0) { if (args.length == 0) {
usage(); usage();
return; return;
@@ -162,11 +165,12 @@ public final class Main {
try (Database db = Database.open(dataDir)) { try (Database db = Database.open(dataDir)) {
AudioIndexer audio = new AudioIndexer(db); AudioIndexer audio = new AudioIndexer(db);
GraphicsIndexer graphics = new GraphicsIndexer(db, dataDir);
DialogueIndexer dialogue = new DialogueIndexer(db); DialogueIndexer dialogue = new DialogueIndexer(db);
RefIndexer refs = new RefIndexer(db, dataDir); RefIndexer refs = new RefIndexer(db, dataDir);
if (!force && audio.pendingCount() == 0 && dialogue.pendingCount() == 0 if (!force && audio.pendingCount() == 0 && graphics.pendingCount() == 0
&& refs.pendingCount() == 0) { && dialogue.pendingCount() == 0 && refs.pendingCount() == 0) {
System.out.println("Nic do przerobienia — wszystko już zbadane."); System.out.println("Nic do przerobienia — wszystko już zbadane.");
return; return;
} }
@@ -178,6 +182,12 @@ public final class Main {
audioStats.audio(), audioStats.archives(), audioStats.entries(), audioStats.audio(), audioStats.archives(), audioStats.entries(),
audioStats.failed()); audioStats.failed());
System.out.println("Dekoduję grafikę i buduję miniatury...");
GraphicsIndexer.Stats graphicsStats = graphics.run(force);
System.out.printf(" %d obrazów, %d animacji (%d klatek), nieczytelnych: %d%n",
graphicsStats.images(), graphicsStats.animations(), graphicsStats.frames(),
graphicsStats.failed());
System.out.println("Czytam tabele dialogów..."); System.out.println("Czytam tabele dialogów...");
DialogueIndexer.Stats dialogueStats = dialogue.run(force); DialogueIndexer.Stats dialogueStats = dialogue.run(force);
System.out.printf(" %d plików, %d wierszy, błędów: %d%n", System.out.printf(" %d plików, %d wierszy, błędów: %d%n",
@@ -632,7 +642,7 @@ public final class Main {
stats statystyki bazy stats statystyki bazy
decode [--force] odszyfruj skrypty do cache'u i indeksu decode [--force] odszyfruj skrypty do cache'u i indeksu
probe [--force] zbadaj dźwięk, archiwa, dialogi i odwołania probe [--force] zbadaj dźwięk, grafikę, dialogi i odwołania
transcribe [--edition N] [--limit N] [--redo] transcribe [--edition N] [--limit N] [--redo]
rozpoznaj mowę przez whisper.cpp (wynik maszynowy) rozpoznaj mowę przez whisper.cpp (wynik maszynowy)
analyze wyciągnij metadane z application.def analyze wyciągnij metadane z application.def
@@ -85,9 +85,10 @@ public final class Database implements AutoCloseable {
} }
} }
/** {@code table_xinfo}, nie {@code table_info}: to drugie pomija kolumny wyliczane. */
private boolean hasColumn(String table, String column) throws SQLException { private boolean hasColumn(String table, String column) throws SQLException {
try (Statement st = connection.createStatement(); try (Statement st = connection.createStatement();
ResultSet rs = st.executeQuery("PRAGMA table_info(" + table + ")")) { ResultSet rs = st.executeQuery("PRAGMA table_xinfo(" + table + ")")) {
while (rs.next()) { while (rs.next()) {
if (column.equalsIgnoreCase(rs.getString("name"))) { if (column.equalsIgnoreCase(rs.getString("name"))) {
return true; return true;
@@ -104,6 +105,36 @@ public final class Database implements AutoCloseable {
*/ */
private void migrate() throws SQLException { private void migrate() throws SQLException {
addColumnIfMissing("copy", "fs_type", "TEXT"); addColumnIfMissing("copy", "fs_type", "TEXT");
// media zaczęło od dźwięku, ale grafika opisuje się tymi samymi kategoriami:
// fakt o blobie, kluczowany odciskiem treści
addColumnIfMissing("media", "width", "INTEGER");
addColumnIfMissing("media", "height", "INTEGER");
addColumnIfMissing("media", "frames", "INTEGER");
addColumnIfMissing("media", "fps", "INTEGER");
addColumnIfMissing("media", "author", "TEXT");
addBasenameIndex();
}
/**
* Nazwa pliku bez katalogu, jako kolumna wyliczana i zaindeksowana.
*
* <p>Rozwiązywanie odwołań ze skryptów pyta o nią raz na odwołanie, a jeden
* skrypt potrafi ich mieć siedemset — bez indeksu każde z nich przeglądało
* wszystkie 38 tysięcy ścieżek. Kolumna jest wirtualna, więc nie zajmuje
* miejsca i nie wymaga uzupełniania przy zapisie.
*/
private void addBasenameIndex() throws SQLException {
if (!hasColumn("file", "basename")) {
try (Statement st = connection.createStatement()) {
st.executeUpdate("ALTER TABLE file ADD COLUMN basename TEXT "
+ "GENERATED ALWAYS AS "
+ "(replace(path, rtrim(path, replace(path, '/', '')), '')) VIRTUAL");
}
}
try (Statement st = connection.createStatement()) {
st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_file_basename "
+ "ON file(copy_id, basename)");
}
} }
private void addColumnIfMissing(String table, String column, String type) throws SQLException { private void addColumnIfMissing(String table, String column, String type) throws SQLException {
@@ -371,6 +402,18 @@ public final class Database implements AutoCloseable {
sha1 UNINDEXED, sha1 UNINDEXED,
tokenize = "unicode61 remove_diacritics 2 tokenchars '_'" tokenize = "unicode61 remove_diacritics 2 tokenchars '_'"
) )
""",
// Nazwane sekwencje w animacji: BEZRUCH, GADA_START, GADA_1...
// Mówią, co postać potrafi zrobić, więc są treścią, a nie tylko liczbą.
"""
CREATE TABLE IF NOT EXISTS anim_event (
ann_sha1 TEXT NOT NULL,
name TEXT NOT NULL,
frames_count INTEGER,
loop_start INTEGER,
loop_end INTEGER,
PRIMARY KEY (ann_sha1, name)
)
""" """
}; };
} }
@@ -98,7 +98,8 @@ public final class Tools {
"Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy " "Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy "
+ "i tablice. Przy kwestiach mówionych podaje długość nagrania, " + "i tablice. Przy kwestiach mówionych podaje długość nagrania, "
+ "postać, która je wypowiada, i zdarzenie, przy którym padają — " + "postać, która je wypowiada, i zdarzenie, przy którym padają — "
+ "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść.", + "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść. "
+ "Przy grafice: wymiary, liczbę klatek animacji i autora.",
schema(Map.of( schema(Map.of(
"ref", property("string", "SHA-1 skryptu albo fragment ścieżki"), "ref", property("string", "SHA-1 skryptu albo fragment ścieżki"),
"format", property("string", "Zawęź do formatu, np. WAV") "format", property("string", "Zawęź do formatu, np. WAV")
@@ -149,32 +150,20 @@ public final class Tools {
sb.append(String.join(", ", search.pathsFor(sha1))).append('\n') sb.append(String.join(", ", search.pathsFor(sha1))).append('\n')
.append(String.join(", ", search.gamesFor(sha1))).append("\n\n"); .append(String.join(", ", search.gamesFor(sha1))).append("\n\n");
String sql = """ String sql = pl.genschu.rexcatalog.media.MediaIndex.RESOLVE_SQL
WITH kopia AS (SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1) + (format == null || format.isBlank() ? "" : " AND r.format = ?5\n")
SELECT r.object, r.field, r.value, r.format, r.line,
COALESCE(
(SELECT f2.blob_sha1 FROM file f2, kopia
WHERE f2.copy_id = kopia.copy_id
AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1),
(SELECT ae.sha1 FROM archive_entry ae
JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia
WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1)
) AS target,
(SELECT d.speaker FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1)
AS speaker,
(SELECT d.trigger FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1)
AS trigger
FROM script_ref r WHERE r.script_sha1 = ?
""" + (format == null || format.isBlank() ? "" : " AND r.format = ?\n")
+ "ORDER BY r.format, r.line"; + "ORDER BY r.format, r.line";
int missing = 0; int missing = 0;
int shown = 0; int shown = 0;
var place = new pl.genschu.rexcatalog.media.MediaIndex(db).place(sha1);
if (place == null) {
return sb + "Tego skryptu nie ma w żadnej kopii.";
}
try (PreparedStatement st = db.connection().prepareStatement(sql)) { try (PreparedStatement st = db.connection().prepareStatement(sql)) {
st.setString(1, sha1); pl.genschu.rexcatalog.media.MediaIndex.bindResolve(st, place, sha1);
st.setString(2, sha1);
if (format != null && !format.isBlank()) { if (format != null && !format.isBlank()) {
st.setString(3, format.toUpperCase(java.util.Locale.ROOT)); st.setString(5, format.toUpperCase(java.util.Locale.ROOT));
} }
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { while (rs.next()) {
@@ -204,18 +193,37 @@ public final class Tools {
return sb.toString(); return sb.toString();
} }
/** Jedna linijka faktów o nagraniu: ile trwa, kto mówi, co słychać. */ /** Jedna linijka faktów o zasobie: ile trwa albo jak duży jest, kto mówi, co widać. */
private String audioDetail(String sha1, String speaker, String trigger) throws Exception { private String audioDetail(String sha1, String speaker, String trigger) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(""" try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.duration_ms, m.codec, t.text, t.model SELECT m.kind, m.duration_ms, m.codec, m.width, m.height, m.frames, m.fps,
m.author, t.text, t.model
FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1 FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1
WHERE m.sha1 = ? AND m.kind = 'audio' WHERE m.sha1 = ?
""")) { """)) {
st.setString(1, sha1); st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
if (!rs.next()) { if (!rs.next()) {
return ""; return "";
} }
String kind = rs.getString("kind");
if ("image".equals(kind) || "anim".equals(kind)) {
StringBuilder graphic = new StringBuilder();
graphic.append(rs.getInt("width")).append('×').append(rs.getInt("height"));
if ("anim".equals(kind)) {
graphic.append(" · ").append(rs.getInt("frames")).append(" klatek");
if (rs.getInt("fps") > 0) {
graphic.append(" · ").append(rs.getInt("fps")).append(" kl./s");
}
}
if (rs.getString("author") != null) {
graphic.append(" · ").append(rs.getString("author"));
}
return graphic.toString();
}
if (!"audio".equals(kind)) {
return "";
}
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(seconds(rs.getInt("duration_ms"))).append(' ') sb.append(seconds(rs.getInt("duration_ms"))).append(' ')
.append(rs.getString("codec")); .append(rs.getString("codec"));
@@ -0,0 +1,291 @@
package pl.genschu.rexcatalog.media;
import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher;
import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem;
import pl.genschu.rexcatalog.db.Database;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Buduje miniatury obrazów i animacji oraz zapisuje ich fakty.
*
* <p>W cache'u ląduje tylko miniatura — pełny obraz odtwarzamy z płyty na żądanie.
* Inaczej 10 459 plików rozpakowanych do PNG zajęłoby więcej niż sama kolekcja,
* a odczyt z obrazu i tak trwa milisekundy.
*
* <p>Animacje dostają dodatkowo listę nazwanych zdarzeń: {@code BEZRUCH},
* {@code GADA_START}, {@code GADA_1} — to opis tego, co postać potrafi zrobić,
* więc traktujemy je jak treść, a nie jak liczbę klatek.
*/
public final class GraphicsIndexer {
public static final String VERSION = "grafika-1";
private static final String KIND = "preview";
/** Dłuższy bok miniatury. Podgląd ma się otwierać od razu, nie ładować. */
private static final int THUMBNAIL = 320;
private final Database db;
private final Path dataDir;
public GraphicsIndexer(Database db, Path dataDir) {
this.db = db;
this.dataDir = dataDir;
}
public record Stats(int images, int animations, int frames, int failed) {
}
private record Pending(String pathInContainer, String sha1, String format) {
}
public Stats run(boolean force) throws Exception {
Map<String, List<Pending>> byContainer = pendingWork(force);
if (byContainer.isEmpty()) {
return new Stats(0, 0, 0, 0);
}
Connection conn = db.connection();
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
int images = 0;
int animations = 0;
int frames = 0;
int failed = 0;
Set<String> done = new HashSet<>();
try (PreparedStatement mediaStmt = conn.prepareStatement("""
INSERT INTO media (sha1, kind, codec, width, height, frames, fps, author,
note, tool_version, probed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sha1) DO UPDATE SET
kind = excluded.kind, codec = excluded.codec, width = excluded.width,
height = excluded.height, frames = excluded.frames, fps = excluded.fps,
author = excluded.author, note = excluded.note,
tool_version = excluded.tool_version, probed_at = excluded.probed_at
""");
PreparedStatement artifactStmt = conn.prepareStatement("""
INSERT INTO artifact (blob_sha1, kind, path, tool_version, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(blob_sha1, kind) DO UPDATE SET
path = excluded.path, tool_version = excluded.tool_version,
created_at = excluded.created_at
""");
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 (?, ?, ?, ?, ?)
ON CONFLICT(ann_sha1, name) DO UPDATE SET
frames_count = excluded.frames_count, loop_start = excluded.loop_start,
loop_end = excluded.loop_end
""")) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
File container = new File(group.getKey());
IFileSystem fs;
try {
fs = AssetSourceDispatcher.openAssets(container);
} catch (Exception e) {
System.err.printf(" ! nie otwieram %s: %s%n",
container.getName(), e.getMessage());
failed += group.getValue().size();
continue;
}
for (Pending pending : group.getValue()) {
if (!done.add(pending.sha1())) {
continue;
}
try {
byte[] raw;
try (InputStream in = fs.open(pending.pathInContainer())) {
raw = in.readAllBytes();
}
if ("ANN".equals(pending.format())) {
frames += indexAnimation(pending.sha1(), raw, mediaStmt, artifactStmt,
eventDelete, eventInsert);
animations++;
} else {
indexImage(pending.sha1(), raw, mediaStmt, artifactStmt);
images++;
}
} catch (Throwable e) {
// uszkodzone i nietypowe pliki zdarzają się na tłoczonych płytach;
// zapisujemy powód, żeby dało się je potem odnaleźć
markUnreadable(mediaStmt, pending, e);
failed++;
}
}
conn.commit();
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
return new Stats(images, animations, frames, failed);
}
private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt,
PreparedStatement artifactStmt) throws Exception {
PikGraphics.Picture picture = PikGraphics.readImage(raw);
writePreview(sha1, picture.image(), artifactStmt);
write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(),
picture.height(), 1, 0, null,
"kompresja " + picture.compression());
}
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
PreparedStatement artifactStmt, PreparedStatement eventDelete,
PreparedStatement eventInsert) throws Exception {
PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
if (!animation.frames().isEmpty()) {
writePreview(sha1, animation.frames().get(0).image(), artifactStmt);
}
write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(),
animation.maxHeight(), animation.frames().size(), animation.fps(),
blankToNull(animation.author()), blankToNull(animation.description()));
eventDelete.setString(1, sha1);
eventDelete.executeUpdate();
for (PikGraphics.AnimEvent event : animation.events()) {
eventInsert.setString(1, sha1);
eventInsert.setString(2, event.name());
eventInsert.setInt(3, event.framesCount());
eventInsert.setInt(4, event.loopStart());
eventInsert.setInt(5, event.loopEnd());
eventInsert.executeUpdate();
}
return animation.frames().size();
}
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt)
throws Exception {
String relative = cachePath(sha1);
Path target = dataDir.resolve(relative);
Files.createDirectories(target.getParent());
ImageIO.write(thumbnail(image), "png", target.toFile());
artifactStmt.setString(1, sha1);
artifactStmt.setString(2, KIND);
artifactStmt.setString(3, relative);
artifactStmt.setString(4, VERSION);
artifactStmt.setString(5, Instant.now().toString());
artifactStmt.executeUpdate();
}
/** Zmniejsza z zachowaniem proporcji; mniejsze obrazy zostawia w spokoju. */
static BufferedImage thumbnail(BufferedImage source) {
int width = source.getWidth();
int height = source.getHeight();
if (width <= THUMBNAIL && height <= THUMBNAIL) {
return source;
}
double scale = (double) THUMBNAIL / Math.max(width, height);
int newWidth = Math.max(1, (int) Math.round(width * scale));
int newHeight = Math.max(1, (int) Math.round(height * scale));
BufferedImage out = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = out.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(source, 0, 0, newWidth, newHeight, null);
g.dispose();
return out;
}
private void markUnreadable(PreparedStatement mediaStmt, Pending pending, Throwable cause)
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);
}
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 {
stmt.setString(1, sha1);
stmt.setString(2, kind);
stmt.setString(3, codec);
stmt.setInt(4, width);
stmt.setInt(5, height);
stmt.setInt(6, frames);
stmt.setInt(7, fps);
stmt.setString(8, author);
stmt.setString(9, note);
stmt.setString(10, VERSION);
stmt.setString(11, Instant.now().toString());
stmt.executeUpdate();
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
/** Rozgałęzienie po dwóch pierwszych znakach — inaczej jeden katalog na 10 tysięcy plików. */
private static String cachePath(String sha1) {
return "artifacts/" + KIND + "/" + sha1.substring(0, 2) + "/" + sha1 + ".png";
}
private Map<String, List<Pending>> pendingWork(boolean force) throws Exception {
String sql = """
SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container,
f.blob_sha1, b.format
FROM copy c
JOIN file f ON f.copy_id = c.id
JOIN blob b ON b.sha1 = f.blob_sha1
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 <> ?)
""") + "ORDER BY c.id";
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) {
st.setString(1, VERSION);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
byContainer.computeIfAbsent(rs.getString("container"), k -> new ArrayList<>())
.add(new Pending(rs.getString("in_container"),
rs.getString("blob_sha1"), rs.getString("format")));
}
}
}
return byContainer;
}
public int pendingCount() throws Exception {
Set<String> unique = new HashSet<>();
for (List<Pending> list : pendingWork(false).values()) {
list.forEach(pending -> unique.add(pending.sha1()));
}
return unique.size();
}
}
@@ -40,6 +40,120 @@ public final class MediaIndex {
String format, long offset, int size) { String format, long offset, int size) {
} }
/**
* Gdzie silnik szuka pliku, do którego odwołuje się skrypt.
*
* <p>Reguły są takie same jak w Piklibie i nie są heurystyką:
* <ul>
* <li>{@code FILENAME=SHIP_MASK.IMG} — bez ścieżki i bez dolara oznacza plik
* <b>obok skryptu</b>, w katalogu sceny;</li>
* <li>{@code $} to <b>korzeń gry</b>, więc {@code $COMMON\DIALOGS.DTA} wskazuje
* {@code <korzeń>/common/dialogs.dta}, a {@code $WAVS\...} — katalog wavs;</li>
* <li>pliki WAV leżą w {@code wavs/}; efekty dźwiękowe nie są tu wołane wprost,
* bo podpina je animacja.</li>
* </ul>
*
* <p>Bez tego rozróżnienia nazwa {@code bkg.img} jest bezużyteczna: w samej
* <i>Wojnie Trojańskiej</i> jest ich siedemnaście, po jednym na planszę.
* Dopasowanie po samej nazwie zostaje jako ostatnia deska ratunku i jest
* oznaczane jako niepewne.
*
* <p>Parametry: katalog skryptu, korzeń gry, identyfikator kopii (kilka razy)
* i odcisk skryptu — podane wprost, a nie liczone we wspólnym wyrażeniu, bo
* SQLite przeliczałby je dla każdego z kilkuset odwołań osobno.
*/
public static final String RESOLVE_SQL = """
SELECT r.object, r.field, r.value, r.name, r.format, r.line,
COALESCE(
-- $ = korzeń gry
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4 AND r.value LIKE '$%'
AND f2.path = ?2 || ltrim(substr(lower(replace(r.value, '\', '/')), 2), '/')
LIMIT 1),
-- obok skryptu
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4 AND f2.path = ?1 || r.name LIMIT 1),
-- ścieżka względna wobec katalogu skryptu
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4
AND f2.path = ?1 || lower(replace(r.value, '\', '/')) LIMIT 1),
-- dźwięk mowy: katalog wavs w korzeniu gry
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4 AND r.format = 'WAV'
AND f2.path = ?2 || 'wavs/' || r.name LIMIT 1),
-- muzyka bywa wprost w korzeniu
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4 AND f2.path = ?2 || r.name LIMIT 1),
-- kwestie spakowane do archiwum wav.snd
(SELECT ae.sha1 FROM archive_entry ae
JOIN file f3 ON f3.blob_sha1 = ae.container_sha1
WHERE f3.copy_id = ?4 AND ae.name = r.name LIMIT 1),
-- ostatnia deska ratunku: ta sama nazwa gdziekolwiek w kopii
(SELECT f2.blob_sha1 FROM file f2
WHERE f2.copy_id = ?4 AND f2.basename = r.name LIMIT 1)
) AS target,
(SELECT COUNT(DISTINCT f2.blob_sha1) FROM file f2
WHERE f2.copy_id = ?4 AND f2.basename = r.name) AS wariantow,
(SELECT 1 FROM file f2
WHERE f2.copy_id = ?4
AND (f2.path = ?1 || r.name
OR f2.path = ?1 || lower(replace(r.value, '\', '/'))
OR (r.value LIKE '$%' AND f2.path = ?2
|| ltrim(substr(lower(replace(r.value, '\', '/')), 2), '/'))
OR (r.format = 'WAV' AND f2.path = ?2 || 'wavs/' || r.name)
OR f2.path = ?2 || r.name)
LIMIT 1) AS pewne,
(SELECT d.speaker FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS speaker,
(SELECT d.trigger FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS trigger
FROM script_ref r
WHERE r.script_sha1 = ?3
""";
/** Katalog skryptu, korzeń gry i kopia — kontekst potrzebny do {@link #RESOLVE_SQL}. */
public record ScriptPlace(long copyId, String dir, String root) {
}
/**
* Ustala, gdzie w kopii leży skrypt i który korzeń gry go obejmuje.
* Na dwupacku liczy się korzeń najgłębszy z pasujących.
*/
public ScriptPlace place(String scriptSha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT f.copy_id,
CASE WHEN instr(f.path, '/') > 0
THEN rtrim(f.path, replace(f.path, '/', ''))
ELSE '' END AS dir,
COALESCE((
SELECT CASE WHEN g.root_path = '' THEN ''
ELSE g.root_path || '/' END
FROM game_root g
WHERE g.copy_id = f.copy_id
AND (g.root_path = '' OR f.path LIKE g.root_path || '/%')
ORDER BY length(g.root_path) DESC LIMIT 1
), '') AS root
FROM file f WHERE f.blob_sha1 = ? LIMIT 1
""")) {
st.setString(1, scriptSha1);
try (ResultSet rs = st.executeQuery()) {
return rs.next()
? new ScriptPlace(rs.getLong("copy_id"), rs.getString("dir"),
rs.getString("root"))
: null;
}
}
}
/** Wiąże parametry {@link #RESOLVE_SQL} w ustalonej kolejności. */
public static void bindResolve(PreparedStatement st, ScriptPlace place, String scriptSha1)
throws java.sql.SQLException {
st.setString(1, place.dir());
st.setString(2, place.root());
st.setString(3, scriptSha1);
st.setLong(4, place.copyId());
}
/** Odczytuje zasób po odcisku treści; {@code null}, gdy nie ma go w katalogu. */ /** Odczytuje zasób po odcisku treści; {@code null}, gdy nie ma go w katalogu. */
public Clip read(String sha1) throws Exception { public Clip read(String sha1) throws Exception {
Location location = locate(sha1); Location location = locate(sha1);
@@ -71,12 +185,11 @@ public final class MediaIndex {
try (PreparedStatement st = db.connection().prepareStatement(""" try (PreparedStatement st = db.connection().prepareStatement("""
SELECT blob_sha1 FROM file SELECT blob_sha1 FROM file
WHERE copy_id = ? AND (path = ? OR path LIKE '%/' || ?) WHERE copy_id = ? AND basename = ?
LIMIT 1 LIMIT 1
""")) { """)) {
st.setLong(1, copyId); st.setLong(1, copyId);
st.setString(2, needle); st.setString(2, needle);
st.setString(3, needle);
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
if (rs.next()) { if (rs.next()) {
return rs.getString(1); return rs.getString(1);
@@ -0,0 +1,361 @@
package pl.genschu.rexcatalog.media;
import pl.genschu.bloomooemulator.encoding.CLZW2Compression;
import pl.genschu.bloomooemulator.encoding.CRLECompression;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Dekoduje grafikę Aidem — obrazy {@code PIK\0} i animacje {@code NVP\0} —
* do zwykłych obrazów Javy, bez OpenGL-a.
*
* <p>Rozpakowanie bierzemy z {@code :core} ({@link CLZW2Compression},
* {@link CRLECompression}), bo to najtrudniejsza część i nie ma sensu jej
* powielać. Nagłówki czytamy tutaj, i to nie z wyboru: {@code ImageLoader}
* trzyma parser w metodzie prywatnej, a {@code AnimoLoader} — mimo że jego
* sygnatura wygląda na niezależną od grafiki — buduje obiekty {@code Image},
* których konstruktor od razu tworzy {@code Texture}. W procesie bez kontekstu
* OpenGL-a jedno i drugie jest nie do użycia. Układ pól odwzorowuje te loadery
* jeden do jednego, żeby katalog i emulator czytały te same bajty tak samo.
*/
public final class PikGraphics {
private static final Charset LATIN1 = StandardCharsets.ISO_8859_1;
/** Rodzaje kompresji, tak jak numeruje je {@code Image.CompressionTypes}. */
private static final int NONE = 0;
private static final int CLZW = 2;
private static final int CRLE_IN_CLZW = 3;
private static final int CRLE = 4;
private static final int JPEG = 5;
private PikGraphics() {
}
public record Picture(int width, int height, int colorDepth, int compression,
int offsetX, int offsetY, BufferedImage image) {
}
public record Frame(String name, int width, int height, int offsetX, int offsetY,
int compression, BufferedImage image) {
}
/** 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) {
}
public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight,
String author, String description, List<Frame> frames,
List<AnimEvent> events) {
}
// ---------- IMG ----------
public static Picture readImage(byte[] data) throws IOException {
Reader reader = new Reader(data);
reader.expectMagic("PIK", "obraz");
int width = reader.i32();
int height = reader.i32();
int colorDepth = reader.i32();
int imageSize = reader.i32();
reader.skip(4);
int compression = reader.i32();
int alphaSize = reader.i32();
int offsetX = reader.i32();
int offsetY = reader.i32();
byte[] imageData = reader.bytes(imageSize);
byte[] alphaData = alphaSize > 0 ? reader.bytes(alphaSize) : null;
// ImageLoader z :core traktuje w plikach IMG kompresję 4 jak jej brak.
// To quirk samego IMG — w klatkach animacji ta sama czwórka oznacza
// prawdziwe CRLE i AnimoLoader przekazuje ją dalej bez zmiany.
BufferedImage image = compose(width, height, colorDepth,
compression == CRLE ? NONE : compression, imageData, alphaData);
return new Picture(width, height, colorDepth, compression, offsetX, offsetY, image);
}
// ---------- ANN ----------
public static Animation readAnimation(byte[] data) throws IOException {
Reader reader = new Reader(data);
reader.expectMagic("NVP", "animacja");
int imagesCount = reader.u16();
int colorDepth = reader.u16();
int eventsCount = reader.u16();
reader.skip(13);
int fps = reader.i32();
reader.skip(4);
int opacity = reader.u8();
reader.skip(12);
String author = reader.lengthPrefixed();
String description = reader.lengthPrefixed();
List<AnimEvent> events = new ArrayList<>();
for (int i = 0; i < eventsCount; i++) {
events.add(readEvent(reader));
}
// najpierw wszystkie nagłówki klatek, dopiero potem ich dane — tak samo
// jak w AnimoLoader, bo tak leżą w pliku
int[][] meta = new int[imagesCount][7];
String[] names = new String[imagesCount];
for (int i = 0; i < imagesCount; i++) {
meta[i][0] = reader.u16(); // szerokość
meta[i][1] = reader.u16(); // wysokość
meta[i][2] = reader.i16(); // przesunięcie X
meta[i][3] = reader.i16(); // przesunięcie Y
meta[i][4] = reader.u16(); // kompresja
meta[i][5] = reader.i32(); // rozmiar danych
reader.skip(14);
meta[i][6] = reader.i32(); // rozmiar warstwy alfa
names[i] = reader.fixedString(20);
}
List<Frame> frames = new ArrayList<>();
int maxWidth = 0;
int maxHeight = 0;
for (int i = 0; i < imagesCount; i++) {
byte[] imageData = reader.bytes(meta[i][5]);
byte[] alphaData = reader.bytes(meta[i][6]);
BufferedImage image = compose(meta[i][0], meta[i][1], colorDepth, meta[i][4],
imageData, alphaData.length == 0 ? null : alphaData);
frames.add(new Frame(names[i], meta[i][0], meta[i][1], meta[i][2], meta[i][3],
meta[i][4], image));
maxWidth = Math.max(maxWidth, meta[i][0]);
maxHeight = Math.max(maxHeight, meta[i][1]);
}
return new Animation(colorDepth, fps, opacity, maxWidth, maxHeight, author, description,
frames, events);
}
private static AnimEvent readEvent(Reader reader) throws IOException {
String name = reader.fixedString(32).toUpperCase(java.util.Locale.ROOT);
int framesCount = reader.u16();
reader.skip(4);
int loopStart = reader.u16();
int loopEnd = reader.u16();
reader.skip(4); // liczba powtórzeń i licznik
reader.skip(4);
reader.skip(4); // flagi
reader.u8(); // krycie
reader.skip(12);
List<Integer> frames = new ArrayList<>(framesCount);
for (int i = 0; i < framesCount; i++) {
frames.add(reader.u16());
}
for (int i = 0; i < framesCount; i++) {
skipFrameData(reader);
}
return new AnimEvent(name, framesCount, loopStart, loopEnd, frames);
}
/** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */
private static void skipFrameData(Reader reader) throws IOException {
reader.skip(4); // bajty startowe
reader.skip(4);
reader.i16(); // przesunięcie X
reader.i16(); // przesunięcie Y
reader.skip(4);
int sfxRandomSeed = reader.i32();
reader.skip(4);
reader.u8(); // krycie
reader.skip(5);
reader.bytes(reader.i32()); // nazwa
if (sfxRandomSeed > 0) {
reader.bytes(reader.i32()); // opis efektu dźwiękowego
}
}
// ---------- piksele ----------
/**
* Składa obraz z warstwy koloru i osobnej warstwy przezroczystości.
*
* <p>Kolor to dwa bajty na piksel (RGB565 albo RGB555, zależnie od głębi),
* alfa — jeden bajt. Odwzorowanie kanałów jest takie samo jak
* w {@code Image.convertRgbToRgb888}.
*/
static BufferedImage compose(int width, int height, int colorDepth, int compression,
byte[] imageData, byte[] alphaData) throws IOException {
if (width <= 0 || height <= 0) {
throw new IOException("nieprawdopodobne wymiary: " + width + "x" + height);
}
if (compression == JPEG) {
return composeJpeg(imageData, alphaData, width, height);
}
byte[] pixels = imageData;
byte[] alpha = alphaData;
if (compression == CRLE) {
pixels = CRLECompression.decodeCRLE(pixels, 2);
if (alpha != null) {
alpha = CRLECompression.decodeCRLE(alpha);
}
}
if (compression == CLZW || compression == CRLE_IN_CLZW) {
pixels = CLZW2Compression.decompress(pixels);
if (alpha != null) {
alpha = CLZW2Compression.decompress(alpha);
}
if (compression == CRLE_IN_CLZW) {
pixels = CRLECompression.decodeCRLE(pixels, 2);
if (alpha != null) {
alpha = CRLECompression.decodeCRLE(alpha);
}
}
}
BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
int count = width * height;
for (int i = 0; i < count; i++) {
int at = i * 2;
if (at + 1 >= pixels.length) {
break;
}
int packed = (pixels[at] & 0xFF) | ((pixels[at + 1] & 0xFF) << 8);
int a = alpha != null && i < alpha.length ? alpha[i] & 0xFF : 255;
out.setRGB(i % width, i / width, (a << 24) | rgb(packed, colorDepth));
}
return out;
}
private static BufferedImage composeJpeg(byte[] imageData, byte[] alphaData,
int width, int height) throws IOException {
BufferedImage jpeg = ImageIO.read(new ByteArrayInputStream(imageData));
if (jpeg == null) {
throw new IOException("nieczytelny strumień JPEG");
}
byte[] alpha = alphaData == null ? null : CLZW2Compression.decompress(alphaData);
BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb = x < jpeg.getWidth() && y < jpeg.getHeight()
? jpeg.getRGB(x, y) & 0xFFFFFF : 0;
int index = y * width + x;
int a = alpha != null && index < alpha.length ? alpha[index] & 0xFF : 255;
out.setRGB(x, y, (a << 24) | rgb);
}
}
return out;
}
/** RGB565 albo RGB555 rozciągnięte na pełne osiem bitów na kanał. */
private static int rgb(int packed, int colorDepth) {
int r;
int g;
int b;
if (colorDepth == 15) {
r = (packed >> 10) & 0x1F;
g = (packed >> 5) & 0x1F;
b = packed & 0x1F;
r = (r << 3) | (r >> 2);
g = (g << 3) | (g >> 2);
b = (b << 3) | (b >> 2);
} else {
r = (packed >> 11) & 0x1F;
g = (packed >> 5) & 0x3F;
b = packed & 0x1F;
r = (r << 3) | (r >> 2);
g = (g << 2) | (g >> 4);
b = (b << 3) | (b >> 2);
}
return (r << 16) | (g << 8) | b;
}
// ---------- odczyt ----------
/** Czytnik po tablicy bajtów; strumieniowy z {@code :core} nie umie cofać. */
private static final class Reader {
private final byte[] data;
private int at;
Reader(byte[] data) {
this.data = data;
}
int u8() throws IOException {
need(1);
return data[at++] & 0xFF;
}
int u16() throws IOException {
need(2);
int value = (data[at] & 0xFF) | ((data[at + 1] & 0xFF) << 8);
at += 2;
return value;
}
int i16() throws IOException {
return (short) u16();
}
int i32() throws IOException {
need(4);
int value = (data[at] & 0xFF) | ((data[at + 1] & 0xFF) << 8)
| ((data[at + 2] & 0xFF) << 16) | ((data[at + 3] & 0xFF) << 24);
at += 4;
return value;
}
void skip(int count) throws IOException {
need(count);
at += count;
}
byte[] bytes(int count) throws IOException {
if (count < 0) {
throw new IOException("ujemna długość bloku: " + count);
}
need(count);
byte[] out = new byte[count];
System.arraycopy(data, at, out, 0, count);
at += count;
return out;
}
/** Magiczne bajty kończy zero, więc porównujemy je wprost, a nie jako tekst. */
void expectMagic(String magic, String what) throws IOException {
byte[] raw = bytes(4);
boolean ok = raw[3] == 0;
for (int i = 0; ok && i < 3; i++) {
ok = raw[i] == (byte) magic.charAt(i);
}
if (!ok) {
throw new IOException("to nie jest " + what + " " + magic
+ " (nagłówek: " + new String(raw, LATIN1).replace("\0", "\\0") + ")");
}
}
String fixedString(int count) throws IOException {
byte[] raw = bytes(count);
int end = 0;
while (end < raw.length && raw[end] != 0) {
end++;
}
return new String(raw, 0, end, LATIN1);
}
String lengthPrefixed() throws IOException {
return new String(bytes(i32()), LATIN1).replace("\0", "");
}
private void need(int count) throws IOException {
if (at + count > data.length) {
throw new IOException("plik urwany na pozycji " + at);
}
}
}
}
@@ -38,8 +38,14 @@ public final class RefIndexer {
"CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA", "CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA",
"KIMODEL", "KIANIM"); "KIMODEL", "KIANIM");
/**
* Nazwa pola bywa zakończona daszkiem i numerem — {@code VARIWST:ONCHANGED^2=...}
* to kolejny wariant tego samego zdarzenia. Bez daszka w zbiorze znaków takie
* linie w ogóle nie trafiałyby do przeszukania, a siedzą w nich prawdziwe
* odwołania, choćby {@code SETBACKGROUND("PARYZ\BKG1.IMG")}.
*/
private static final Pattern ASSIGNMENT = private static final Pattern ASSIGNMENT =
Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_]+)=(.*)$"); Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_^]+)=(.*)$");
/** /**
* Nazwa nie może być doklejona plusem: {@code "$COMMON\"+ZMIENNA+"_DEF.DTA"} * Nazwa nie może być doklejona plusem: {@code "$COMMON\"+ZMIENNA+"_DEF.DTA"}
@@ -48,7 +54,10 @@ public final class RefIndexer {
* _WZIECIE_JABLKA.WAV} to prawdziwy plik. * _WZIECIE_JABLKA.WAV} to prawdziwy plik.
*/ */
private static final Pattern FILENAME = Pattern.compile( private static final Pattern FILENAME = Pattern.compile(
"(?<!\\+)(?<!\\+\")" // dopasowanie musi zaczynać się na granicy tokenu: bez tego zablokowanie
// startu na podkreśleniu sprawia, że silnik rusza znak dalej i wycina
// HIST0.ARR ze środka +"_HIST0.ARR"
"(?<![A-Za-z0-9_$])(?<!\\+)(?<!\\+\")"
+ "[A-Za-z0-9_$][A-Za-z0-9_$\\\\/.-]*\\.(" + "[A-Za-z0-9_$][A-Za-z0-9_$\\\\/.-]*\\.("
+ String.join("|", EXTENSIONS) + ")\\b", + String.join("|", EXTENSIONS) + ")\\b",
Pattern.CASE_INSENSITIVE); Pattern.CASE_INSENSITIVE);
@@ -84,6 +93,15 @@ public final class RefIndexer {
INSERT INTO script_ref (script_sha1, object, field, value, name, format, line) INSERT INTO script_ref (script_sha1, object, field, value, name, format, line)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(script_sha1, object, field, value) DO NOTHING ON CONFLICT(script_sha1, object, field, value) DO NOTHING
""");
// ślad przebiegu — bez niego skrypty bez odwołań (a takich jest ponad sto)
// przechodziłyby przez parser przy każdym uruchomieniu
PreparedStatement mark = conn.prepareStatement("""
INSERT INTO media (sha1, kind, note, tool_version, probed_at)
VALUES (?, 'script', ?, ?, ?)
ON CONFLICT(sha1) DO UPDATE SET
kind = excluded.kind, note = excluded.note,
tool_version = excluded.tool_version, probed_at = excluded.probed_at
""")) { """)) {
for (Map.Entry<String, String> entry : pending.entrySet()) { for (Map.Entry<String, String> entry : pending.entrySet()) {
@@ -93,8 +111,15 @@ public final class RefIndexer {
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
delete.setString(1, sha1); delete.setString(1, sha1);
delete.executeUpdate(); delete.executeUpdate();
refs += extract(sha1, body, insert); int written = extract(sha1, body, insert);
refs += written;
scripts++; scripts++;
mark.setString(1, sha1);
mark.setString(2, written + " odwołań do zasobów");
mark.setString(3, VERSION);
mark.setString(4, java.time.Instant.now().toString());
mark.executeUpdate();
} catch (Exception e) { } catch (Exception e) {
System.err.printf(" ! %s: %s%n", sha1, e.getMessage()); System.err.printf(" ! %s: %s%n", sha1, e.getMessage());
failed++; failed++;
@@ -152,16 +177,21 @@ public final class RefIndexer {
String sql = """ String sql = """
SELECT a.blob_sha1, a.path SELECT a.blob_sha1, a.path
FROM artifact a FROM artifact a
LEFT JOIN media m ON m.sha1 = a.blob_sha1 AND m.kind = 'script'
WHERE a.kind = 'script' WHERE a.kind = 'script'
""" + (force ? "" : """ """ + (force ? "" : """
AND NOT EXISTS (SELECT 1 FROM script_ref r WHERE r.script_sha1 = a.blob_sha1) AND (m.sha1 IS NULL OR m.tool_version <> ?)
"""); """);
Map<String, String> out = new LinkedHashMap<>(); Map<String, String> out = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql); try (PreparedStatement st = db.connection().prepareStatement(sql)) {
ResultSet rs = st.executeQuery()) { if (!force) {
while (rs.next()) { st.setString(1, VERSION);
out.put(rs.getString("blob_sha1"), rs.getString("path")); }
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
out.put(rs.getString("blob_sha1"), rs.getString("path"));
}
} }
} }
return out; return out;
@@ -92,6 +92,12 @@ public final class Transcriber {
return new Tool(binary, null, false, return new Tool(binary, null, false,
"nie znalazłem modelu — wskaż przez -Dcatalog.whisper.model=ścieżka.bin"); "nie znalazłem modelu — wskaż przez -Dcatalog.whisper.model=ścieżka.bin");
} }
// w kontenerze model jest montowany z zewnątrz, więc ścieżka bywa wskazana,
// a pliku pod nią nie ma; lepiej powiedzieć to wprost niż wywalić się w trakcie
if (!Files.isReadable(Path.of(model))) {
return new Tool(binary, model, false,
"pod wskazaną ścieżką nie ma modelu: " + model);
}
if (!ffmpegAvailable()) { if (!ffmpegAvailable()) {
return new Tool(binary, model, false, return new Tool(binary, model, false,
"brakuje ffmpeg, a nagrania trzeba przepróbkować do 16 kHz mono"); "brakuje ffmpeg, a nagrania trzeba przepróbkować do 16 kHz mono");
@@ -6,6 +6,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import pl.genschu.rexcatalog.db.Database; import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.media.MediaIndex; import pl.genschu.rexcatalog.media.MediaIndex;
import pl.genschu.rexcatalog.media.PikGraphics;
import pl.genschu.rexcatalog.script.ScriptSearch; import pl.genschu.rexcatalog.script.ScriptSearch;
import pl.genschu.rexcatalog.transcribe.Transcriber; import pl.genschu.rexcatalog.transcribe.Transcriber;
@@ -31,6 +32,7 @@ import java.util.Map;
public final class WebServer { public final class WebServer {
private final Database db; private final Database db;
private final Path dataDir;
private final ScriptSearch search; private final ScriptSearch search;
private final MediaIndex media; private final MediaIndex media;
private final Transcriber transcriber; private final Transcriber transcriber;
@@ -38,6 +40,7 @@ public final class WebServer {
public WebServer(Database db, Path dataDir, Object lock) { public WebServer(Database db, Path dataDir, Object lock) {
this.db = db; this.db = db;
this.dataDir = dataDir;
this.search = new ScriptSearch(db, dataDir); this.search = new ScriptSearch(db, dataDir);
this.media = new MediaIndex(db); this.media = new MediaIndex(db);
this.transcriber = new Transcriber(db, lock); this.transcriber = new Transcriber(db, lock);
@@ -94,11 +97,20 @@ public final class WebServer {
String path = exchange.getRequestURI().getPath().substring("/api".length()); String path = exchange.getRequestURI().getPath().substring("/api".length());
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery()); Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
// dźwięk wychodzi bajtami, a nie JSON-em — obsługiwany przed resztą tras // dźwięk i obrazy wychodzą bajtami, a nie JSON-em
if (path.startsWith("/audio/")) { if (path.startsWith("/audio/")) {
serveAudio(exchange, path.substring("/audio/".length())); serveAudio(exchange, path.substring("/audio/".length()));
return; return;
} }
if (path.startsWith("/preview/")) {
servePreview(exchange, path.substring("/preview/".length()));
return;
}
if (path.startsWith("/render/")) {
serveRender(exchange, path.substring("/render/".length()),
parseInt(query.get("frame"), 0));
return;
}
Object payload; Object payload;
synchronized (lock) { synchronized (lock) {
@@ -200,6 +212,100 @@ public final class WebServer {
exchange.getResponseBody().write(clip.bytes()); exchange.getResponseBody().write(clip.bytes());
} }
// ---------- grafika ----------
/** Miniatura z cache'u — mała i gotowa, do przeglądania list. */
private void servePreview(HttpExchange exchange, String sha1) throws IOException {
String relative;
try {
synchronized (lock) {
relative = previewPath(sha1);
}
} catch (Exception e) {
respond(exchange, 500, "text/plain", String.valueOf(e.getMessage()).getBytes(UTF8));
return;
}
if (relative == null) {
respond(exchange, 404, "text/plain", "Brak miniatury".getBytes(UTF8));
return;
}
java.nio.file.Path file = dataDir.resolve(relative);
if (!java.nio.file.Files.isReadable(file)) {
respond(exchange, 404, "text/plain",
"Miniatura zniknęła z cache'u — uruchom probe".getBytes(UTF8));
return;
}
byte[] bytes = java.nio.file.Files.readAllBytes(file);
exchange.getResponseHeaders().set("Content-Type", "image/png");
exchange.getResponseHeaders().set("Cache-Control", "public, max-age=86400");
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
}
/**
* Pełny obraz odtwarzany z płyty na żądanie. W cache'u trzymamy tylko miniatury,
* bo rozpakowana grafika zajęłaby więcej niż sama kolekcja, a odczyt z obrazu
* i rozpakowanie trwają milisekundy.
*/
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());
if (animation.frames().isEmpty()) {
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
return;
}
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
image = animation.frames().get(index).image();
} else {
image = PikGraphics.readImage(clip.bytes()).image();
}
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
javax.imageio.ImageIO.write(image, "png", buffer);
png = buffer.toByteArray();
} catch (Exception e) {
respond(exchange, 500, "text/plain",
("Nie zdekodowałem: " + e.getMessage()).getBytes(UTF8));
return;
}
exchange.getResponseHeaders().set("Content-Type", "image/png");
exchange.getResponseHeaders().set("Cache-Control", "public, max-age=3600");
exchange.sendResponseHeaders(200, png.length);
exchange.getResponseBody().write(png);
}
private String previewPath(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
private String mediaKind(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT kind FROM media WHERE sha1 = ?")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
// ---------- transkrypcja ---------- // ---------- transkrypcja ----------
private JsonObject transcribeStatus() { private JsonObject transcribeStatus() {
@@ -256,6 +362,10 @@ public final class WebServer {
out.addProperty("dialogue", scalar("SELECT COUNT(*) FROM dialogue_line")); out.addProperty("dialogue", scalar("SELECT COUNT(*) FROM dialogue_line"));
out.addProperty("refs", scalar("SELECT COUNT(*) FROM script_ref")); out.addProperty("refs", scalar("SELECT COUNT(*) FROM script_ref"));
out.addProperty("transcripts", scalar("SELECT COUNT(*) FROM transcript")); out.addProperty("transcripts", scalar("SELECT COUNT(*) FROM transcript"));
out.addProperty("images", scalar("SELECT COUNT(*) FROM media WHERE kind = 'image'"));
out.addProperty("animations", scalar("SELECT COUNT(*) FROM media WHERE kind = 'anim'"));
out.addProperty("anim_frames",
scalar("SELECT COALESCE(SUM(frames), 0) FROM media WHERE kind = 'anim'"));
JsonArray formats = new JsonArray(); JsonArray formats = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement( try (PreparedStatement st = db.connection().prepareStatement(
@@ -492,29 +602,13 @@ public final class WebServer {
*/ */
private JsonArray refs(String scriptSha1) throws Exception { private JsonArray refs(String scriptSha1) throws Exception {
JsonArray out = new JsonArray(); JsonArray out = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(""" MediaIndex.ScriptPlace place = media.place(scriptSha1);
WITH kopia AS ( if (place == null) {
SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1 return out;
) }
SELECT r.object, r.field, r.value, r.name, r.format, r.line, try (PreparedStatement st = db.connection().prepareStatement(
COALESCE( MediaIndex.RESOLVE_SQL + "ORDER BY r.line, r.object")) {
(SELECT f2.blob_sha1 FROM file f2, kopia MediaIndex.bindResolve(st, place, scriptSha1);
WHERE f2.copy_id = kopia.copy_id
AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1),
(SELECT ae.sha1 FROM archive_entry ae
JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia
WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1)
) AS target,
(SELECT d.speaker FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS speaker,
(SELECT d.trigger FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS trigger
FROM script_ref r
WHERE r.script_sha1 = ?
ORDER BY r.line, r.object
""")) {
st.setString(1, scriptSha1);
st.setString(2, scriptSha1);
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { while (rs.next()) {
JsonObject row = new JsonObject(); JsonObject row = new JsonObject();
@@ -523,6 +617,11 @@ public final class WebServer {
row.addProperty(column, rs.getString(column)); row.addProperty(column, rs.getString(column));
} }
row.addProperty("line", rs.getInt("line")); row.addProperty("line", rs.getInt("line"));
// trafienie spoza reguł silnika, przy kilku plikach o tej nazwie,
// jest domysłem, nie faktem — front musi to pokazać
row.addProperty("uncertain", rs.getInt("pewne") == 0
&& rs.getInt("wariantow") > 1);
row.addProperty("variants", rs.getInt("wariantow"));
out.add(row); out.add(row);
} }
} }
@@ -557,7 +656,8 @@ public final class WebServer {
} }
String placeholders = String.join(",", java.util.Collections.nCopies(sha1s.size(), "?")); String placeholders = String.join(",", java.util.Collections.nCopies(sha1s.size(), "?"));
try (PreparedStatement st = db.connection().prepareStatement(""" try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.sha1, m.codec, m.duration_ms, m.sample_rate, m.channels, SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.sample_rate, m.channels,
m.width, m.height, m.frames, m.fps, m.author, m.note,
t.text AS transcript, t.model AS transcript_model t.text AS transcript, t.model AS transcript_model
FROM media m FROM media m
LEFT JOIN transcript t ON t.sha1 = m.sha1 LEFT JOIN transcript t ON t.sha1 = m.sha1
@@ -570,10 +670,17 @@ public final class WebServer {
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { while (rs.next()) {
JsonObject row = new JsonObject(); JsonObject row = new JsonObject();
row.addProperty("kind", rs.getString("kind"));
row.addProperty("codec", rs.getString("codec")); row.addProperty("codec", rs.getString("codec"));
row.addProperty("duration_ms", rs.getInt("duration_ms")); row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("sample_rate", rs.getInt("sample_rate")); row.addProperty("sample_rate", rs.getInt("sample_rate"));
row.addProperty("channels", rs.getInt("channels")); row.addProperty("channels", rs.getInt("channels"));
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("author", rs.getString("author"));
row.addProperty("note", rs.getString("note"));
row.addProperty("transcript", rs.getString("transcript")); row.addProperty("transcript", rs.getString("transcript"));
row.addProperty("transcript_model", rs.getString("transcript_model")); row.addProperty("transcript_model", rs.getString("transcript_model"));
out.put(rs.getString("sha1"), row); out.put(rs.getString("sha1"), row);
@@ -654,7 +761,8 @@ public final class WebServer {
JsonObject out = new JsonObject(); JsonObject out = new JsonObject();
try (PreparedStatement st = db.connection().prepareStatement(""" try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.sample_rate, m.channels, SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.sample_rate, m.channels,
m.bits, m.note, t.text AS transcript, t.model AS transcript_model, m.bits, m.note, m.width, m.height, m.frames, m.fps, m.author,
t.text AS transcript, t.model AS transcript_model,
t.lang AS transcript_lang, t.created_at AS transcript_at t.lang AS transcript_lang, t.created_at AS transcript_at
FROM media m FROM media m
LEFT JOIN transcript t ON t.sha1 = m.sha1 LEFT JOIN transcript t ON t.sha1 = m.sha1
@@ -665,7 +773,7 @@ public final class WebServer {
if (!rs.next()) { if (!rs.next()) {
return null; return null;
} }
for (String column : List.of("kind", "codec", "note", "transcript", for (String column : List.of("kind", "codec", "note", "author", "transcript",
"transcript_model", "transcript_lang", "transcript_at")) { "transcript_model", "transcript_lang", "transcript_at")) {
out.addProperty(column, rs.getString(column)); out.addProperty(column, rs.getString(column));
} }
@@ -674,6 +782,10 @@ public final class WebServer {
out.addProperty("sample_rate", rs.getInt("sample_rate")); out.addProperty("sample_rate", rs.getInt("sample_rate"));
out.addProperty("channels", rs.getInt("channels")); out.addProperty("channels", rs.getInt("channels"));
out.addProperty("bits", rs.getInt("bits")); out.addProperty("bits", rs.getInt("bits"));
out.addProperty("width", rs.getInt("width"));
out.addProperty("height", rs.getInt("height"));
out.addProperty("frames", rs.getInt("frames"));
out.addProperty("fps", rs.getInt("fps"));
} }
} }
@@ -756,6 +868,25 @@ public final class WebServer {
} }
} }
out.add("used_by", usedBy); out.add("used_by", usedBy);
JsonArray events = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT name, frames_count, loop_start, loop_end
FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid
""")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("name", rs.getString("name"));
row.addProperty("frames_count", rs.getInt("frames_count"));
row.addProperty("loop_start", rs.getInt("loop_start"));
row.addProperty("loop_end", rs.getInt("loop_end"));
events.add(row);
}
}
}
out.add("events", events);
return out; return out;
} }
+124 -1
View File
@@ -91,6 +91,27 @@ a { color: var(--accent); }
.kwestia { grid-column: 1 / -1; font-size: 12.5px; padding: 2px 0 0 2px; .kwestia { grid-column: 1 / -1; font-size: 12.5px; padding: 2px 0 0 2px;
border-left: 2px solid var(--accent); padding-left: 8px; } border-left: 2px solid var(--accent); padding-left: 8px; }
.maszyna { color: var(--dim); font-size: 11px; font-style: italic; } .maszyna { color: var(--dim); font-size: 11px; font-style: italic; }
/* szachownica pod przezroczystością — inaczej nie widać, gdzie kończy się obrazek */
.kratka { background-image:
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
background-size: 12px 12px; background-position: 0 0, 6px 6px; }
.galeria { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px; margin-top: 6px; }
.kafel { border: 1px solid var(--line); border-radius: 6px; overflow: hidden;
cursor: pointer; background: var(--bg); }
.kafel:hover { border-color: var(--accent); }
.kafel img { display: block; width: 100%; height: 74px; object-fit: contain; }
.kafel .pod { font-size: 10.5px; padding: 3px 4px; color: var(--dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--mono); }
#zaslona { position: fixed; inset: 0; background: rgba(0,0,0,.72); z-index: 10;
display: flex; align-items: center; justify-content: center; padding: 24px; }
#zaslona > div { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px; max-width: min(920px, 94vw);
max-height: 92vh; overflow: auto; }
#zaslona img.duzy { max-width: 100%; max-height: 58vh; object-fit: contain;
display: block; margin: 0 auto; }
.postep { display: flex; align-items: center; gap: 10px; margin: 10px 0; } .postep { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
.postep .bar { flex: 1; height: 10px; } .postep .bar { flex: 1; height: 10px; }
button.akcja { border: 1px solid var(--line); background: var(--panel); color: var(--ink); button.akcja { border: 1px solid var(--line); background: var(--panel); color: var(--ink);
@@ -172,6 +193,8 @@ button.akcja:disabled { opacity: .45; cursor: default; }
</section> </section>
</main> </main>
<div id="zaslona" hidden></div>
<script> <script>
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => const esc = s => String(s ?? '').replace(/[&<>"]/g, c =>
@@ -359,6 +382,9 @@ async function otworzSkrypt(sha1) {
</div> </div>
${rysujZasoby(s.refs || [])} ${rysujZasoby(s.refs || [])}
<div class="card"><pre>${esc(s.body)}</pre></div>`; <div class="card"><pre>${esc(s.body)}</pre></div>`;
$('#podglad').querySelectorAll('[data-zasob]').forEach(k =>
k.onclick = () => otworzZasob(k.dataset.zasob));
} }
const czas = ms => ms == null ? '' : const czas = ms => ms == null ? '' :
@@ -373,7 +399,10 @@ const czas = ms => ms == null ? '' :
function rysujZasoby(refs) { function rysujZasoby(refs) {
if (!refs.length) return ''; if (!refs.length) return '';
const audio = refs.filter(r => r.format === 'WAV' || r.format === 'OGG'); const audio = refs.filter(r => r.format === 'WAV' || r.format === 'OGG');
const reszta = refs.filter(r => !(r.format === 'WAV' || r.format === 'OGG')); const grafika = refs.filter(r => r.target && (r.format === 'IMG' || r.format === 'ANN'));
const rysowalne = new Set(grafika.map(r => r.target));
const reszta = refs.filter(r => !(r.format === 'WAV' || r.format === 'OGG')
&& !(r.target && rysowalne.has(r.target) && (r.format === 'IMG' || r.format === 'ANN')));
const brak = refs.filter(r => !r.target).length; const brak = refs.filter(r => !r.target).length;
const wiersz = r => { const wiersz = r => {
@@ -384,6 +413,8 @@ function rysujZasoby(refs) {
<span class="mono">${esc(r.value)}</span> <span class="mono">${esc(r.value)}</span>
<span class="dim" style="font-size:11.5px"> ${esc(r.object)}:${esc(r.field)}</span> <span class="dim" style="font-size:11.5px"> ${esc(r.object)}:${esc(r.field)}</span>
${opis ? `<div class="dim" style="font-size:12px">${opis}</div>` : ''} ${opis ? `<div class="dim" style="font-size:12px">${opis}</div>` : ''}
${r.uncertain ? `<div class="dim" style="font-size:11px">dopasowanie niepewne —
${r.variants} pliki o tej nazwie</div>` : ''}
</div> </div>
<div>${r.target <div>${r.target
? `<audio controls preload="none" src="/api/audio/${r.target}"></audio> ? `<audio controls preload="none" src="/api/audio/${r.target}"></audio>
@@ -400,6 +431,8 @@ function rysujZasoby(refs) {
${brak ? `<span class="dim" style="font-weight:400;font-size:12px"> ${brak ? `<span class="dim" style="font-weight:400;font-size:12px">
· ${brak} bez pliku na płycie</span>` : ''}</h2> · ${brak} bez pliku na płycie</span>` : ''}</h2>
${audio.length ? `<h3>Dźwięk (${audio.length})</h3>${audio.map(wiersz).join('')}` : ''} ${audio.length ? `<h3>Dźwięk (${audio.length})</h3>${audio.map(wiersz).join('')}` : ''}
${grafika.length ? `<h3>Grafika (${grafika.length})</h3>
<div class="galeria">${grafika.map(kafel).join('')}</div>` : ''}
${reszta.length ? `<h3>Pozostałe (${reszta.length})</h3> ${reszta.length ? `<h3>Pozostałe (${reszta.length})</h3>
<div style="columns:2;column-gap:20px;font-size:12.5px"> <div style="columns:2;column-gap:20px;font-size:12.5px">
${reszta.map(r => `<div class="mono" style="break-inside:avoid;padding:1px 0"> ${reszta.map(r => `<div class="mono" style="break-inside:avoid;padding:1px 0">
@@ -409,6 +442,18 @@ function rysujZasoby(refs) {
</div>`; </div>`;
} }
const kafel = r => {
const m = r.media || {};
const opis = m.kind === 'anim' ? `${m.frames} kl.` : `${m.width}×${m.height}`;
// przy kilku plikach o tej nazwie poza katalogiem skryptu to tylko domysł
const tytul = esc(r.value) + ' · ' + opis
+ (r.uncertain ? ` · niepewne, ${r.variants} pliki o tej nazwie` : '');
return `<div class="kafel" data-zasob="${r.target}" title="${tytul}">
<img class="kratka" loading="lazy" src="/api/preview/${r.target}" alt="${esc(r.value)}">
<div class="pod">${r.uncertain ? '<span title="dopasowanie niepewne">?</span> ' : ''}${esc(r.name)}</div>
</div>`;
};
// ---- dźwięk ---- // ---- dźwięk ----
let czekajD; let czekajD;
$('#szukajdzwiek').oninput = e => { $('#szukajdzwiek').oninput = e => {
@@ -483,6 +528,82 @@ async function otworzNagranie(sha1) {
a.onclick = e => { e.preventDefault(); otworzSkrypt(a.dataset.skrypt); }); a.onclick = e => { e.preventDefault(); otworzSkrypt(a.dataset.skrypt); });
} }
// ---- 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">
<h2 class="mono" style="font-size:13px">${esc(m.places[0]?.path || sha1)}</h2>
<span class="dim" style="font-size:12px;margin-left:auto">${esc(m.places[0]?.copy || '')}</span>
<button class="akcja" id="zamknij">Zamknij</button>
</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>` : ''}
<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>` : ''}
<dt>Format</dt><dd>${esc(m.codec)}${m.note && !m.note.startsWith('nieczytelny')
? ' · ' + esc(m.note) : ''}</dd>
${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>` : ''}
</div>`;
$('#zaslona').hidden = false;
$('#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);
}
}
function zamknijZaslone() {
$('#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();
}
});
// ---- transkrypcja ---- // ---- transkrypcja ----
let pytajOStan = null; let pytajOStan = null;
@@ -545,6 +666,8 @@ function sledzPostep() {
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików ` `${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów ` + `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) ` + `· ${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.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n)); const max = Math.max(...s.formats.map(f => f.n));