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:
co-authored by
Claude Opus 5
parent
6feb2254b2
commit
f1b36f0e29
+25
-4
@@ -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
|
||||
|
||||
+7
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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 {
|
||||
@@ -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)
|
||||
)
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
"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(
|
||||
"(?<!\\+)(?<!\\+\")"
|
||||
// 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_$\\\\/.-]*\\.("
|
||||
+ String.join("|", EXTENSIONS) + ")\\b",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
@@ -84,6 +93,15 @@ public final class RefIndexer {
|
||||
INSERT INTO script_ref (script_sha1, object, field, value, name, format, line)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
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()) {
|
||||
@@ -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,18 +177,23 @@ 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<String, String> out = new LinkedHashMap<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement(sql);
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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/")) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="zaslona" hidden></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s ?? '').replace(/[&<>"]/g, c =>
|
||||
@@ -359,6 +382,9 @@ async function otworzSkrypt(sha1) {
|
||||
</div>
|
||||
${rysujZasoby(s.refs || [])}
|
||||
<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 ? '' :
|
||||
@@ -373,7 +399,10 @@ const czas = ms => ms == null ? '' :
|
||||
function rysujZasoby(refs) {
|
||||
if (!refs.length) return '';
|
||||
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 wiersz = r => {
|
||||
@@ -384,6 +413,8 @@ function rysujZasoby(refs) {
|
||||
<span class="mono">${esc(r.value)}</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>` : ''}
|
||||
${r.uncertain ? `<div class="dim" style="font-size:11px">dopasowanie niepewne —
|
||||
${r.variants} pliki o tej nazwie</div>` : ''}
|
||||
</div>
|
||||
<div>${r.target
|
||||
? `<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} bez pliku na płycie</span>` : ''}</h2>
|
||||
${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>
|
||||
<div style="columns:2;column-gap:20px;font-size:12.5px">
|
||||
${reszta.map(r => `<div class="mono" style="break-inside:avoid;padding:1px 0">
|
||||
@@ -409,6 +442,18 @@ function rysujZasoby(refs) {
|
||||
</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 ----
|
||||
let czekajD;
|
||||
$('#szukajdzwiek').oninput = e => {
|
||||
@@ -483,6 +528,82 @@ async function otworzNagranie(sha1) {
|
||||
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 ----
|
||||
let pytajOStan = null;
|
||||
|
||||
@@ -545,6 +666,8 @@ function sledzPostep() {
|
||||
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
|
||||
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
|
||||
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) `
|
||||
+ `· ${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
|
||||
+ `(${s.anim_frames.toLocaleString('pl')} klatek) `
|
||||
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
|
||||
|
||||
const max = Math.max(...s.formats.map(f => f.n));
|
||||
|
||||
Reference in New Issue
Block a user