diff --git a/Dockerfile b/Dockerfile index 4a967a9..5873f79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,15 +15,36 @@ COPY src ./src RUN ./gradlew --no-daemon installDist \ && 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 WORKDIR /app # obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen 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 -# kwestii bierzemy z dialogi.dta. Front powie wprost, że narzędzia brakuje. -# Kto chce transkrybować, robi to na hoście: rex-catalog transcribe. + +# whisper.cpp i ffmpeg wchodzą do obrazu, bo to kilkadziesiąt megabajtów. +# Model NIE wchodzi: waży kilkaset megabajtów do kilku gigabajtów, każdy trzyma +# 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 # to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu ENV CATALOG_DATA=/data diff --git a/compose.yaml b/compose.yaml index 4e43d23..74d14a5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,8 +9,14 @@ services: # kolekcja tylko do odczytu: katalog niczego w niej nie zmienia, # a obrazy płyt są nieodtwarzalne - ${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 + # 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 volumes: diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index a414433..5ebf12e 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -8,6 +8,7 @@ import pl.genschu.rexcatalog.ingest.LanguageDetector; import pl.genschu.rexcatalog.ingest.MetadataDetector; import pl.genschu.rexcatalog.media.AudioIndexer; import pl.genschu.rexcatalog.media.DialogueIndexer; +import pl.genschu.rexcatalog.media.GraphicsIndexer; import pl.genschu.rexcatalog.script.RefIndexer; import pl.genschu.rexcatalog.transcribe.Transcriber; import pl.genschu.rexcatalog.mcp.McpServer; @@ -38,6 +39,8 @@ public final class Main { System.getProperty("catalog.coreVersion", "nieznana"); 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) { usage(); return; @@ -162,11 +165,12 @@ public final class Main { try (Database db = Database.open(dataDir)) { AudioIndexer audio = new AudioIndexer(db); + GraphicsIndexer graphics = new GraphicsIndexer(db, dataDir); DialogueIndexer dialogue = new DialogueIndexer(db); RefIndexer refs = new RefIndexer(db, dataDir); - if (!force && audio.pendingCount() == 0 && dialogue.pendingCount() == 0 - && refs.pendingCount() == 0) { + if (!force && audio.pendingCount() == 0 && graphics.pendingCount() == 0 + && dialogue.pendingCount() == 0 && refs.pendingCount() == 0) { System.out.println("Nic do przerobienia — wszystko już zbadane."); return; } @@ -178,6 +182,12 @@ public final class Main { audioStats.audio(), audioStats.archives(), audioStats.entries(), 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..."); DialogueIndexer.Stats dialogueStats = dialogue.run(force); System.out.printf(" %d plików, %d wierszy, błędów: %d%n", @@ -632,7 +642,7 @@ public final class Main { stats statystyki bazy 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] rozpoznaj mowę przez whisper.cpp (wynik maszynowy) analyze wyciągnij metadane z application.def diff --git a/src/main/java/pl/genschu/rexcatalog/db/Database.java b/src/main/java/pl/genschu/rexcatalog/db/Database.java index 4f6abe7..f8686b0 100644 --- a/src/main/java/pl/genschu/rexcatalog/db/Database.java +++ b/src/main/java/pl/genschu/rexcatalog/db/Database.java @@ -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 { try (Statement st = connection.createStatement(); - ResultSet rs = st.executeQuery("PRAGMA table_info(" + table + ")")) { + ResultSet rs = st.executeQuery("PRAGMA table_xinfo(" + table + ")")) { while (rs.next()) { if (column.equalsIgnoreCase(rs.getString("name"))) { return true; @@ -104,6 +105,36 @@ public final class Database implements AutoCloseable { */ private void migrate() throws SQLException { 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. + * + *

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 { @@ -371,6 +402,18 @@ public final class Database implements AutoCloseable { sha1 UNINDEXED, 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) + ) """ }; } diff --git a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java index 966032c..e26d035 100644 --- a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java +++ b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java @@ -98,7 +98,8 @@ public final class Tools { "Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy " + "i tablice. Przy kwestiach mówionych podaje długość nagrania, " + "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( "ref", property("string", "SHA-1 skryptu albo fragment ścieżki"), "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') .append(String.join(", ", search.gamesFor(sha1))).append("\n\n"); - String sql = """ - WITH kopia AS (SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1) - 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") + String sql = pl.genschu.rexcatalog.media.MediaIndex.RESOLVE_SQL + + (format == null || format.isBlank() ? "" : " AND r.format = ?5\n") + "ORDER BY r.format, r.line"; int missing = 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)) { - st.setString(1, sha1); - st.setString(2, sha1); + pl.genschu.rexcatalog.media.MediaIndex.bindResolve(st, place, sha1); 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()) { while (rs.next()) { @@ -204,18 +193,37 @@ public final class Tools { 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 { 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 - WHERE m.sha1 = ? AND m.kind = 'audio' + WHERE m.sha1 = ? """)) { st.setString(1, sha1); try (ResultSet rs = st.executeQuery()) { if (!rs.next()) { 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(); sb.append(seconds(rs.getInt("duration_ms"))).append(' ') .append(rs.getString("codec")); diff --git a/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java new file mode 100644 index 0000000..085f508 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java @@ -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. + * + *

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. + * + *

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> 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 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> 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> 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> 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 unique = new HashSet<>(); + for (List list : pendingWork(false).values()) { + list.forEach(pending -> unique.add(pending.sha1())); + } + return unique.size(); + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java b/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java index 19c26d8..89d6dfe 100644 --- a/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java +++ b/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java @@ -40,6 +40,120 @@ public final class MediaIndex { String format, long offset, int size) { } + /** + * Gdzie silnik szuka pliku, do którego odwołuje się skrypt. + * + *

Reguły są takie same jak w Piklibie i nie są heurystyką: + *

+ * + *

Bez tego rozróżnienia nazwa {@code bkg.img} jest bezużyteczna: w samej + * Wojnie Trojańskiej jest ich siedemnaście, po jednym na planszę. + * Dopasowanie po samej nazwie zostaje jako ostatnia deska ratunku i jest + * oznaczane jako niepewne. + * + *

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. */ public Clip read(String sha1) throws Exception { Location location = locate(sha1); @@ -71,12 +185,11 @@ public final class MediaIndex { try (PreparedStatement st = db.connection().prepareStatement(""" SELECT blob_sha1 FROM file - WHERE copy_id = ? AND (path = ? OR path LIKE '%/' || ?) + WHERE copy_id = ? AND basename = ? LIMIT 1 """)) { st.setLong(1, copyId); st.setString(2, needle); - st.setString(3, needle); try (ResultSet rs = st.executeQuery()) { if (rs.next()) { return rs.getString(1); diff --git a/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java new file mode 100644 index 0000000..433aeb7 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java @@ -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. + * + *

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 frames) { + } + + public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight, + String author, String description, List frames, + List 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 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 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 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. + * + *

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); + } + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java index 7348b13..5b2d222 100644 --- a/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java +++ b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java @@ -38,8 +38,14 @@ public final class RefIndexer { "CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA", "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 = - 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"} @@ -48,7 +54,10 @@ public final class RefIndexer { * _WZIECIE_JABLKA.WAV} to prawdziwy plik. */ private static final Pattern FILENAME = Pattern.compile( - "(? entry : pending.entrySet()) { @@ -93,8 +111,15 @@ public final class RefIndexer { StandardCharsets.UTF_8); delete.setString(1, sha1); delete.executeUpdate(); - refs += extract(sha1, body, insert); + int written = extract(sha1, body, insert); + refs += written; 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) { System.err.printf(" ! %s: %s%n", sha1, e.getMessage()); failed++; @@ -152,16 +177,21 @@ public final class RefIndexer { String sql = """ SELECT a.blob_sha1, a.path FROM artifact a + LEFT JOIN media m ON m.sha1 = a.blob_sha1 AND m.kind = 'script' WHERE a.kind = 'script' """ + (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 out = new LinkedHashMap<>(); - try (PreparedStatement st = db.connection().prepareStatement(sql); - ResultSet rs = st.executeQuery()) { - while (rs.next()) { - out.put(rs.getString("blob_sha1"), rs.getString("path")); + try (PreparedStatement st = db.connection().prepareStatement(sql)) { + if (!force) { + st.setString(1, VERSION); + } + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + out.put(rs.getString("blob_sha1"), rs.getString("path")); + } } } return out; diff --git a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java index 9380193..a3f1260 100644 --- a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java +++ b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java @@ -92,6 +92,12 @@ public final class Transcriber { return new Tool(binary, null, false, "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()) { return new Tool(binary, model, false, "brakuje ffmpeg, a nagrania trzeba przepróbkować do 16 kHz mono"); diff --git a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java index 03a049b..faf15e1 100644 --- a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java +++ b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java @@ -6,6 +6,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import pl.genschu.rexcatalog.db.Database; import pl.genschu.rexcatalog.media.MediaIndex; +import pl.genschu.rexcatalog.media.PikGraphics; import pl.genschu.rexcatalog.script.ScriptSearch; import pl.genschu.rexcatalog.transcribe.Transcriber; @@ -31,6 +32,7 @@ import java.util.Map; public final class WebServer { private final Database db; + private final Path dataDir; private final ScriptSearch search; private final MediaIndex media; private final Transcriber transcriber; @@ -38,6 +40,7 @@ public final class WebServer { public WebServer(Database db, Path dataDir, Object lock) { this.db = db; + this.dataDir = dataDir; this.search = new ScriptSearch(db, dataDir); this.media = new MediaIndex(db); this.transcriber = new Transcriber(db, lock); @@ -94,11 +97,20 @@ public final class WebServer { String path = exchange.getRequestURI().getPath().substring("/api".length()); Map 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/")) { serveAudio(exchange, path.substring("/audio/".length())); 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; synchronized (lock) { @@ -200,6 +212,100 @@ public final class WebServer { 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 ---------- private JsonObject transcribeStatus() { @@ -256,6 +362,10 @@ public final class WebServer { out.addProperty("dialogue", scalar("SELECT COUNT(*) FROM dialogue_line")); out.addProperty("refs", scalar("SELECT COUNT(*) FROM script_ref")); 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(); try (PreparedStatement st = db.connection().prepareStatement( @@ -492,29 +602,13 @@ public final class WebServer { */ private JsonArray refs(String scriptSha1) throws Exception { JsonArray out = new JsonArray(); - try (PreparedStatement st = db.connection().prepareStatement(""" - WITH kopia AS ( - SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1 - ) - SELECT r.object, r.field, r.value, r.name, 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 = ? - ORDER BY r.line, r.object - """)) { - st.setString(1, scriptSha1); - st.setString(2, scriptSha1); + MediaIndex.ScriptPlace place = media.place(scriptSha1); + if (place == null) { + return out; + } + try (PreparedStatement st = db.connection().prepareStatement( + MediaIndex.RESOLVE_SQL + "ORDER BY r.line, r.object")) { + MediaIndex.bindResolve(st, place, scriptSha1); try (ResultSet rs = st.executeQuery()) { while (rs.next()) { JsonObject row = new JsonObject(); @@ -523,6 +617,11 @@ public final class WebServer { row.addProperty(column, rs.getString(column)); } 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); } } @@ -557,7 +656,8 @@ public final class WebServer { } String placeholders = String.join(",", java.util.Collections.nCopies(sha1s.size(), "?")); 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 FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1 @@ -570,10 +670,17 @@ public final class WebServer { try (ResultSet rs = st.executeQuery()) { while (rs.next()) { JsonObject row = new JsonObject(); + row.addProperty("kind", rs.getString("kind")); row.addProperty("codec", rs.getString("codec")); row.addProperty("duration_ms", rs.getInt("duration_ms")); row.addProperty("sample_rate", rs.getInt("sample_rate")); 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_model", rs.getString("transcript_model")); out.put(rs.getString("sha1"), row); @@ -654,7 +761,8 @@ public final class WebServer { JsonObject out = new JsonObject(); try (PreparedStatement st = db.connection().prepareStatement(""" 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 FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1 @@ -665,7 +773,7 @@ public final class WebServer { if (!rs.next()) { 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")) { out.addProperty(column, rs.getString(column)); } @@ -674,6 +782,10 @@ public final class WebServer { out.addProperty("sample_rate", rs.getInt("sample_rate")); out.addProperty("channels", rs.getInt("channels")); 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); + + 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; } diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 6375db1..ec7845f 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -91,6 +91,27 @@ a { color: var(--accent); } .kwestia { grid-column: 1 / -1; font-size: 12.5px; padding: 2px 0 0 2px; border-left: 2px solid var(--accent); padding-left: 8px; } .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 .bar { flex: 1; height: 10px; } button.akcja { border: 1px solid var(--line); background: var(--panel); color: var(--ink); @@ -172,6 +193,8 @@ button.akcja:disabled { opacity: .45; cursor: default; } +

+