feat: add asset browser and ANN event playback

This commit is contained in:
Patryk Gensch
2026-08-25 00:01:21 +02:00
parent c2036e1f72
commit 8a870a60bb
6 changed files with 769 additions and 91 deletions
+7 -3
View File
@@ -76,9 +76,13 @@ rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście
rex-catalog titles # lista wydań z metadanymi rex-catalog titles # lista wydań z metadanymi
``` ```
Front ma cztery widoki: **Katalog** (tytuły, wydania, kopie, pliki), **Skrypty** Front otwiera się na widoku **Zasoby** — paginowanej galerii obrazów, animacji i nagrań,
(wyszukiwanie pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie filtrowanej po grze, rodzaju i nazwie. Zaznaczenie otwiera inspektor z pełnym podglądem,
nagrań, odtwarzacz, transkrypcja) i **Formaty** (rozkład zawartości kolekcji). parametrami i miejscami użycia. Animacje można odtwarzać w tempie zapisanym w ANN — jako
wszystkie obrazy albo konkretne nazwane zdarzenie, np. `BEZRUCH` czy `GADA_1`, razem z jego
właściwą kolejnością klatek i zakresem pętli. Dalej są **Skrypty** (wyszukiwanie
pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie nagrań,
odtwarzacz, transkrypcja), **Kolekcja** (tytuły, wydania, kopie, pliki) i **Statystyki**.
Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami — Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami —
od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt
@@ -112,6 +112,11 @@ public final class Database implements AutoCloseable {
addColumnIfMissing("media", "frames", "INTEGER"); addColumnIfMissing("media", "frames", "INTEGER");
addColumnIfMissing("media", "fps", "INTEGER"); addColumnIfMissing("media", "fps", "INTEGER");
addColumnIfMissing("media", "author", "TEXT"); addColumnIfMissing("media", "author", "TEXT");
// Starszy indeks zdarzeń przechowywał tylko ich długość. Do wiernego
// podglądu potrzebna jest właściwa kolejność obrazów i reguły pętli.
addColumnIfMissing("anim_event", "frames", "TEXT");
addColumnIfMissing("anim_event", "repeat_count", "INTEGER");
addColumnIfMissing("anim_event", "flags", "INTEGER");
addBasenameIndex(); addBasenameIndex();
} }
@@ -412,6 +417,9 @@ public final class Database implements AutoCloseable {
frames_count INTEGER, frames_count INTEGER,
loop_start INTEGER, loop_start INTEGER,
loop_end INTEGER, loop_end INTEGER,
frames TEXT,
repeat_count INTEGER,
flags INTEGER,
PRIMARY KEY (ann_sha1, name) PRIMARY KEY (ann_sha1, name)
) )
""" """
@@ -36,7 +36,8 @@ import java.util.Set;
*/ */
public final class GraphicsIndexer { public final class GraphicsIndexer {
public static final String VERSION = "grafika-1"; public static final String IMAGE_VERSION = "grafika-1";
public static final String ANIMATION_VERSION = "grafika-2";
private static final String KIND = "preview"; private static final String KIND = "preview";
@@ -93,11 +94,13 @@ public final class GraphicsIndexer {
PreparedStatement eventDelete = conn.prepareStatement( PreparedStatement eventDelete = conn.prepareStatement(
"DELETE FROM anim_event WHERE ann_sha1 = ?"); "DELETE FROM anim_event WHERE ann_sha1 = ?");
PreparedStatement eventInsert = conn.prepareStatement(""" PreparedStatement eventInsert = conn.prepareStatement("""
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end) INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end,
VALUES (?, ?, ?, ?, ?) frames, repeat_count, flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ann_sha1, name) DO UPDATE SET ON CONFLICT(ann_sha1, name) DO UPDATE SET
frames_count = excluded.frames_count, loop_start = excluded.loop_start, frames_count = excluded.frames_count, loop_start = excluded.loop_start,
loop_end = excluded.loop_end loop_end = excluded.loop_end, frames = excluded.frames,
repeat_count = excluded.repeat_count, flags = excluded.flags
""")) { """)) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) { for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
@@ -152,11 +155,11 @@ public final class GraphicsIndexer {
private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt, private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt,
PreparedStatement artifactStmt) throws Exception { PreparedStatement artifactStmt) throws Exception {
PikGraphics.Picture picture = PikGraphics.readImage(raw); PikGraphics.Picture picture = PikGraphics.readImage(raw);
writePreview(sha1, picture.image(), artifactStmt); writePreview(sha1, picture.image(), artifactStmt, IMAGE_VERSION);
write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(), write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(),
picture.height(), 1, 0, null, picture.height(), 1, 0, null,
"kompresja " + picture.compression()); "kompresja " + picture.compression(), IMAGE_VERSION);
} }
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt, private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
@@ -164,12 +167,14 @@ public final class GraphicsIndexer {
PreparedStatement eventInsert) throws Exception { PreparedStatement eventInsert) throws Exception {
PikGraphics.Animation animation = PikGraphics.readAnimation(raw); PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
if (!animation.frames().isEmpty()) { if (!animation.frames().isEmpty()) {
writePreview(sha1, animation.frames().get(0).image(), artifactStmt); writePreview(sha1, animation.frames().get(0).image(), artifactStmt,
ANIMATION_VERSION);
} }
write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(), write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(),
animation.maxHeight(), animation.frames().size(), animation.fps(), animation.maxHeight(), animation.frames().size(), animation.fps(),
blankToNull(animation.author()), blankToNull(animation.description())); blankToNull(animation.author()), blankToNull(animation.description()),
ANIMATION_VERSION);
eventDelete.setString(1, sha1); eventDelete.setString(1, sha1);
eventDelete.executeUpdate(); eventDelete.executeUpdate();
@@ -179,13 +184,17 @@ public final class GraphicsIndexer {
eventInsert.setInt(3, event.framesCount()); eventInsert.setInt(3, event.framesCount());
eventInsert.setInt(4, event.loopStart()); eventInsert.setInt(4, event.loopStart());
eventInsert.setInt(5, event.loopEnd()); eventInsert.setInt(5, event.loopEnd());
eventInsert.setString(6, event.frames().stream()
.map(String::valueOf).collect(java.util.stream.Collectors.joining(",")));
eventInsert.setInt(7, event.repeatCount());
eventInsert.setInt(8, event.flags());
eventInsert.executeUpdate(); eventInsert.executeUpdate();
} }
return animation.frames().size(); return animation.frames().size();
} }
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt) private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt,
throws Exception { String version) throws Exception {
String relative = cachePath(sha1); String relative = cachePath(sha1);
Path target = dataDir.resolve(relative); Path target = dataDir.resolve(relative);
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
@@ -194,7 +203,7 @@ public final class GraphicsIndexer {
artifactStmt.setString(1, sha1); artifactStmt.setString(1, sha1);
artifactStmt.setString(2, KIND); artifactStmt.setString(2, KIND);
artifactStmt.setString(3, relative); artifactStmt.setString(3, relative);
artifactStmt.setString(4, VERSION); artifactStmt.setString(4, version);
artifactStmt.setString(5, Instant.now().toString()); artifactStmt.setString(5, Instant.now().toString());
artifactStmt.executeUpdate(); artifactStmt.executeUpdate();
} }
@@ -223,12 +232,13 @@ public final class GraphicsIndexer {
throws Exception { throws Exception {
String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage(); String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage();
write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image", write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image",
null, 0, 0, 0, 0, null, "nieczytelny: " + reason); null, 0, 0, 0, 0, null, "nieczytelny: " + reason,
"ANN".equals(pending.format()) ? ANIMATION_VERSION : IMAGE_VERSION);
} }
private void write(PreparedStatement stmt, String sha1, String kind, String codec, private void write(PreparedStatement stmt, String sha1, String kind, String codec,
int width, int height, int frames, int fps, String author, String note) int width, int height, int frames, int fps, String author, String note,
throws Exception { String version) throws Exception {
stmt.setString(1, sha1); stmt.setString(1, sha1);
stmt.setString(2, kind); stmt.setString(2, kind);
stmt.setString(3, codec); stmt.setString(3, codec);
@@ -238,7 +248,7 @@ public final class GraphicsIndexer {
stmt.setInt(7, fps); stmt.setInt(7, fps);
stmt.setString(8, author); stmt.setString(8, author);
stmt.setString(9, note); stmt.setString(9, note);
stmt.setString(10, VERSION); stmt.setString(10, version);
stmt.setString(11, Instant.now().toString()); stmt.setString(11, Instant.now().toString());
stmt.executeUpdate(); stmt.executeUpdate();
} }
@@ -262,13 +272,15 @@ public final class GraphicsIndexer {
LEFT JOIN media m ON m.sha1 = b.sha1 LEFT JOIN media m ON m.sha1 = b.sha1
WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN') WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN')
""" + (force ? "" : """ """ + (force ? "" : """
AND (m.sha1 IS NULL OR m.tool_version <> ?) AND (m.sha1 IS NULL OR m.tool_version <>
CASE b.format WHEN 'ANN' THEN ? ELSE ? END)
""") + "ORDER BY c.id"; """) + "ORDER BY c.id";
Map<String, List<Pending>> byContainer = new LinkedHashMap<>(); Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) { try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) { if (!force) {
st.setString(1, VERSION); st.setString(1, ANIMATION_VERSION);
st.setString(2, IMAGE_VERSION);
} }
try (ResultSet rs = st.executeQuery()) { try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { while (rs.next()) {
@@ -49,7 +49,7 @@ public final class PikGraphics {
/** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */ /** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd, public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd,
List<Integer> frames) { int repeatCount, int flags, List<Integer> frames) {
} }
public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight, public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight,
@@ -144,9 +144,10 @@ public final class PikGraphics {
reader.skip(4); reader.skip(4);
int loopStart = reader.u16(); int loopStart = reader.u16();
int loopEnd = reader.u16(); int loopEnd = reader.u16();
reader.skip(4); // liczba powtórzeń i licznik int repeatCount = reader.u16();
reader.skip(2); // bieżący licznik powtórzeń
reader.skip(4); reader.skip(4);
reader.skip(4); // flagi int flags = reader.i32();
reader.u8(); // krycie reader.u8(); // krycie
reader.skip(12); reader.skip(12);
@@ -157,7 +158,7 @@ public final class PikGraphics {
for (int i = 0; i < framesCount; i++) { for (int i = 0; i < framesCount; i++) {
skipFrameData(reader); skipFrameData(reader);
} }
return new AnimEvent(name, framesCount, loopStart, loopEnd, frames); return new AnimEvent(name, framesCount, loopStart, loopEnd, repeatCount, flags, frames);
} }
/** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */ /** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */
@@ -37,6 +37,9 @@ public final class WebServer {
private final MediaIndex media; private final MediaIndex media;
private final Transcriber transcriber; private final Transcriber transcriber;
private final Object lock; private final Object lock;
private final Object animationCacheLock = new Object();
private String cachedAnimationSha1;
private PikGraphics.Animation cachedAnimation;
public WebServer(Database db, Path dataDir, Object lock) { public WebServer(Database db, Path dataDir, Object lock) {
this.db = db; this.db = db;
@@ -139,6 +142,9 @@ public final class WebServer {
if (path.equals("/titles")) { if (path.equals("/titles")) {
return titles(); return titles();
} }
if (path.equals("/assets")) {
return assets(query);
}
if (path.equals("/search")) { if (path.equals("/search")) {
return searchScripts(query.getOrDefault("q", ""), return searchScripts(query.getOrDefault("q", ""),
parseInt(query.get("limit"), 30)); parseInt(query.get("limit"), 30));
@@ -250,19 +256,17 @@ public final class WebServer {
private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException { private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException {
byte[] png; byte[] png;
try { try {
MediaIndex.Clip clip;
String kind; String kind;
synchronized (lock) { synchronized (lock) {
clip = media.read(sha1);
kind = mediaKind(sha1); kind = mediaKind(sha1);
} }
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
java.awt.image.BufferedImage image; java.awt.image.BufferedImage image;
if ("anim".equals(kind)) { if ("anim".equals(kind)) {
PikGraphics.Animation animation = PikGraphics.readAnimation(clip.bytes()); PikGraphics.Animation animation = animation(sha1);
if (animation == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
if (animation.frames().isEmpty()) { if (animation.frames().isEmpty()) {
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8)); respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
return; return;
@@ -270,6 +274,14 @@ public final class WebServer {
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1)); int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
image = animation.frames().get(index).image(); image = animation.frames().get(index).image();
} else { } else {
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
image = PikGraphics.readImage(clip.bytes()).image(); image = PikGraphics.readImage(clip.bytes()).image();
} }
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
@@ -286,6 +298,31 @@ public final class WebServer {
exchange.getResponseBody().write(png); exchange.getResponseBody().write(png);
} }
/**
* Trzyma ostatnio oglądane ANN w pamięci. Odtwarzacz prosi o kilkanaście
* klatek na sekundę, a ponowne rozpakowywanie całego pliku dla każdej z nich
* byłoby znacznie droższe niż sam zapis PNG. Jedna pozycja ogranicza koszt
* pamięci także dla animacji z dużymi obrazami.
*/
private PikGraphics.Animation animation(String sha1) throws Exception {
synchronized (animationCacheLock) {
if (sha1.equals(cachedAnimationSha1)) {
return cachedAnimation;
}
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
return null;
}
PikGraphics.Animation decoded = PikGraphics.readAnimation(clip.bytes());
cachedAnimationSha1 = sha1;
cachedAnimation = decoded;
return decoded;
}
}
private String previewPath(String sha1) throws Exception { private String previewPath(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement( try (PreparedStatement st = db.connection().prepareStatement(
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) { "SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
@@ -555,6 +592,146 @@ public final class WebServer {
return out; return out;
} }
/**
* Paginowana lista zasobów do głównej galerii. Jedna treść może występować
* na kilku płytach, dlatego karta jest kluczowana SHA-1, a liczba miejsc
* pozostaje jawna. Wpisy archiwów SND są włączone do tej samej listy co
* zwykłe pliki — z punktu widzenia przeglądającego oba są nagraniami.
*/
private JsonObject assets(Map<String, String> query) throws Exception {
Integer edition = query.containsKey("edition")
? parseInt(query.get("edition"), -1) : null;
if (edition != null && edition < 0) {
edition = null;
}
String kind = query.getOrDefault("kind", "visual").toLowerCase(Locale.ROOT);
if (!List.of("visual", "image", "anim", "audio", "all").contains(kind)) {
kind = "visual";
}
String text = query.getOrDefault("q", "").trim().toLowerCase(Locale.ROOT);
int limit = Math.max(1, Math.min(parseInt(query.get("limit"), 80), 200));
int offset = Math.max(0, parseInt(query.get("offset"), 0));
String sort = query.getOrDefault("sort", "name");
String places = """
WITH places AS (
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(f.path_raw, f.path) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
0 AS in_archive
FROM media m
JOIN file f ON f.blob_sha1 = m.sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
UNION ALL
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(ae.name_raw, ae.name) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
1 AS in_archive
FROM media m
JOIN archive_entry ae ON ae.sha1 = m.sha1
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
)
""";
StringBuilder where = new StringBuilder(" WHERE 1 = 1\n");
List<Object> params = new ArrayList<>();
switch (kind) {
case "image", "anim", "audio" -> {
where.append(" AND kind = ?\n");
params.add(kind);
}
case "visual" -> where.append(" AND kind IN ('image', 'anim')\n");
default -> { }
}
if (edition != null) {
where.append(" AND edition_id = ?\n");
params.add(edition);
}
if (!text.isBlank()) {
where.append(" AND (lower(path) LIKE ? OR lower(COALESCE(title, '')) LIKE ? "
+ "OR lower(COALESCE(copy, '')) LIKE ? OR lower(COALESCE(codec, '')) LIKE ?)\n");
String like = "%" + text + "%";
params.add(like);
params.add(like);
params.add(like);
params.add(like);
}
long total;
try (PreparedStatement st = db.connection().prepareStatement(
places + "SELECT COUNT(DISTINCT sha1) FROM places" + where)) {
bind(st, params);
try (ResultSet rs = st.executeQuery()) {
total = rs.next() ? rs.getLong(1) : 0;
}
}
String order = switch (sort) {
case "game" -> "lower(COALESCE(MIN(title), '')), lower(MIN(path)), sha1";
case "largest" -> "COALESCE(MAX(width), 0) * COALESCE(MAX(height), 0) "
+ "DESC, lower(MIN(path)), sha1";
default -> "lower(MIN(path)), sha1";
};
String sql = places + """
SELECT sha1, MIN(kind) AS kind, MIN(codec) AS codec,
MAX(duration_ms) AS duration_ms, MAX(width) AS width,
MAX(height) AS height, MAX(frames) AS frames, MAX(fps) AS fps,
MIN(path) AS path, MIN(copy) AS copy, MIN(edition_id) AS edition_id,
MIN(title) AS title, MAX(in_archive) AS in_archive,
COUNT(*) AS places_count, COUNT(DISTINCT edition_id) AS games_count
FROM places
""" + where + " GROUP BY sha1 ORDER BY " + order + " LIMIT ? OFFSET ?";
JsonArray items = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
bind(st, pageParams);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("sha1", rs.getString("sha1"));
row.addProperty("kind", rs.getString("kind"));
row.addProperty("codec", rs.getString("codec"));
row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("width", rs.getInt("width"));
row.addProperty("height", rs.getInt("height"));
row.addProperty("frames", rs.getInt("frames"));
row.addProperty("fps", rs.getInt("fps"));
row.addProperty("path", rs.getString("path"));
row.addProperty("name", baseName(rs.getString("path")));
row.addProperty("copy", rs.getString("copy"));
row.addProperty("edition_id", rs.getInt("edition_id"));
row.addProperty("title", rs.getString("title"));
row.addProperty("in_archive", rs.getInt("in_archive") == 1);
row.addProperty("places_count", rs.getInt("places_count"));
row.addProperty("games_count", rs.getInt("games_count"));
items.add(row);
}
}
}
JsonObject out = new JsonObject();
out.addProperty("total", total);
out.addProperty("offset", offset);
out.addProperty("limit", limit);
out.addProperty("has_more", offset + items.size() < total);
out.add("items", items);
return out;
}
private JsonObject searchScripts(String query, int limit) throws Exception { private JsonObject searchScripts(String query, int limit) throws Exception {
JsonObject out = new JsonObject(); JsonObject out = new JsonObject();
if (query.isBlank()) { if (query.isBlank()) {
@@ -871,7 +1048,7 @@ public final class WebServer {
JsonArray events = new JsonArray(); JsonArray events = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(""" try (PreparedStatement st = db.connection().prepareStatement("""
SELECT name, frames_count, loop_start, loop_end SELECT name, frames_count, loop_start, loop_end, frames, repeat_count, flags
FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid
""")) { """)) {
st.setString(1, sha1); st.setString(1, sha1);
@@ -882,6 +1059,16 @@ public final class WebServer {
row.addProperty("frames_count", rs.getInt("frames_count")); row.addProperty("frames_count", rs.getInt("frames_count"));
row.addProperty("loop_start", rs.getInt("loop_start")); row.addProperty("loop_start", rs.getInt("loop_start"));
row.addProperty("loop_end", rs.getInt("loop_end")); row.addProperty("loop_end", rs.getInt("loop_end"));
row.addProperty("repeat_count", rs.getInt("repeat_count"));
row.addProperty("flags", rs.getInt("flags"));
JsonArray frames = new JsonArray();
String encoded = rs.getString("frames");
if (encoded != null && !encoded.isBlank()) {
for (String value : encoded.split(",")) {
frames.add(parseInt(value, 0));
}
}
row.add("frames", frames);
events.add(row); events.add(row);
} }
} }
@@ -900,6 +1087,21 @@ public final class WebServer {
return array; return array;
} }
private static void bind(PreparedStatement statement, List<Object> values)
throws Exception {
for (int i = 0; i < values.size(); i++) {
statement.setObject(i + 1, values.get(i));
}
}
private static String baseName(String path) {
if (path == null) {
return null;
}
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return slash >= 0 ? path.substring(slash + 1) : path;
}
private long scalar(String sql) throws Exception { private long scalar(String sql) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(sql); try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) { ResultSet rs = st.executeQuery()) {
+510 -59
View File
@@ -6,13 +6,16 @@
<title>rex-catalog</title> <title>rex-catalog</title>
<style> <style>
:root { :root {
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368; --bg: #f4f2ed; --panel: #fffefa; --panel-2: #ebe8e0; --line: #d9d4c9;
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace; --ink: #292721; --dim: #736e64; --accent: #9b5c28; --accent-soft: #f2e3d1;
--hl: #f2e6d5; --shadow: 0 8px 30px rgba(55, 45, 31, .08);
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c; --bg: #171613; --panel: #211f1a; --panel-2: #2a2721; --line: #3c3830;
--accent: #d3a05e; --hl: #3d3323; --ink: #eee9df; --dim: #aaa294; --accent: #dfa45f; --accent-soft: #3a2c20;
--hl: #342b21; --shadow: 0 12px 34px rgba(0, 0, 0, .24);
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -21,19 +24,23 @@ body {
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
} }
header { header {
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel); min-height: 62px; padding: 10px 18px; border-bottom: 1px solid var(--line);
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap; background: color-mix(in srgb, var(--panel) 94%, transparent);
display: flex; gap: 18px; align-items: center; position: sticky; top: 0; z-index: 8;
backdrop-filter: blur(14px);
} }
h1 { margin: 0; font-size: 17px; letter-spacing: .02em; } h1 { margin: 0; font-size: 18px; letter-spacing: -.02em; white-space: nowrap; }
h1 span { color: var(--accent); } h1 span { color: var(--accent); }
#stats { color: var(--dim); font-size: 12.5px; } #stats { color: var(--dim); font-size: 12px; white-space: nowrap; }
nav { margin-left: auto; display: flex; gap: 4px; } nav { margin-left: auto; display: flex; gap: 3px; padding: 3px; border-radius: 9px;
background: var(--panel-2); }
nav button { nav button {
border: 1px solid var(--line); background: transparent; color: var(--ink); border: 0; background: transparent; color: var(--dim);
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px; padding: 7px 12px; border-radius: 7px; cursor: pointer; font-size: 13px;
} }
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; } nav button:hover { color: var(--ink); }
main { padding: 18px 20px; max-width: 1500px; } nav button.on { background: var(--panel); color: var(--ink); box-shadow: 0 1px 5px rgba(0,0,0,.12); }
main { padding: 16px 18px; max-width: 1800px; margin: 0 auto; }
/* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja /* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja
z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */ z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */
[hidden] { display: none !important; } [hidden] { display: none !important; }
@@ -118,6 +125,129 @@ button.akcja { border: 1px solid var(--line); background: var(--panel); color: v
padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; } padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; }
button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.akcja:disabled { opacity: .45; cursor: default; } button.akcja:disabled { opacity: .45; cursor: default; }
/* Główna przeglądarka zasobów. Trzy kolumny zachowują kontekst: wybór kolekcji,
wyniki i szczegóły zaznaczonego elementu są widoczne jednocześnie. */
.asset-layout { display: grid; grid-template-columns: 218px minmax(440px, 1fr) 330px;
gap: 14px; min-height: calc(100vh - 95px); align-items: stretch; }
.asset-sidebar, .asset-browser, .asset-inspector {
background: var(--panel); border: 1px solid var(--line); border-radius: 11px;
min-width: 0;
}
.asset-sidebar { padding: 14px 10px; }
.side-heading { margin: 4px 8px 7px; color: var(--dim); font-size: 10.5px;
font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.side-heading:not(:first-child) { margin-top: 22px; }
.side-button { display: flex; width: 100%; align-items: center; gap: 8px; border: 0;
padding: 7px 9px; border-radius: 7px; background: transparent; color: var(--ink);
cursor: pointer; text-align: left; font: inherit; font-size: 12.5px; }
.side-button:hover { background: var(--panel-2); }
.side-button.on { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
.side-button .count { margin-left: auto; color: var(--dim); font-size: 11px;
font-variant-numeric: tabular-nums; }
.kind-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--line); flex: 0 0 auto; }
.side-button.on .kind-dot { background: var(--accent); }
.game-buttons { max-height: calc(100vh - 390px); overflow-y: auto; scrollbar-width: thin; }
.asset-browser { display: flex; flex-direction: column; overflow: hidden; }
.asset-toolbar { padding: 13px 14px; border-bottom: 1px solid var(--line); }
.asset-title-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.asset-title-row h2 { font-size: 16px; margin: 0; }
.asset-title-row .result-count { color: var(--dim); font-size: 12px; }
.asset-controls { display: grid; grid-template-columns: minmax(180px, 1fr) 135px; gap: 8px; }
.search-wrap { position: relative; }
.search-wrap::before { content: '⌕'; color: var(--dim); position: absolute; left: 11px; top: 5px;
font-size: 19px; pointer-events: none; }
.search-wrap input { padding-left: 34px; background: var(--bg); }
.asset-content { flex: 1; padding: 14px; overflow: auto; }
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 11px; align-content: start; }
.asset-card { display: block; padding: 0; overflow: hidden; border: 1px solid var(--line);
border-radius: 9px; background: var(--bg); color: var(--ink); cursor: pointer;
text-align: left; min-width: 0; font: inherit; transition: border-color .12s, transform .12s,
box-shadow .12s; }
.asset-card:hover { border-color: color-mix(in srgb, var(--accent) 68%, var(--line));
transform: translateY(-1px); box-shadow: var(--shadow); }
.asset-card.on { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.asset-thumb { height: 122px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); overflow: hidden; }
.asset-thumb img { width: 100%; height: 100%; display: block; object-fit: contain; }
.asset-audio-art { width: 58px; height: 58px; border-radius: 50%; display: grid; place-items: center;
color: var(--accent); background: var(--accent-soft); font-size: 25px; }
.asset-card-body { padding: 8px 9px 9px; }
.asset-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--mono); font-size: 11.5px; font-weight: 650; }
.asset-game { color: var(--dim); font-size: 10.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.asset-badges { display: flex; gap: 4px; align-items: center; margin-top: 6px; }
.asset-badge { padding: 1px 5px; border-radius: 4px; background: var(--panel-2);
color: var(--dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .03em; }
.asset-badge.frames { color: var(--accent); background: var(--accent-soft); }
.asset-more { padding: 18px 0 5px; text-align: center; }
.skeleton { min-height: 180px; border-radius: 9px; background: linear-gradient(90deg,
var(--panel-2), var(--bg), var(--panel-2)); background-size: 200% 100%;
animation: shimmer 1.2s infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
.asset-inspector { overflow: auto; position: sticky; top: 78px; max-height: calc(100vh - 95px); }
.inspector-empty { height: 100%; min-height: 340px; display: grid; place-items: center;
padding: 28px; color: var(--dim); text-align: center; }
.inspector-hero { min-height: 235px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); border-bottom: 1px solid var(--line); overflow: hidden;
border-radius: 10px 10px 0 0; }
.inspector-hero img { display: block; max-width: 100%; max-height: 330px; object-fit: contain; }
.inspector-body { padding: 15px; }
.inspector-body h2 { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere;
margin-bottom: 4px; }
.inspector-sub { color: var(--dim); font-size: 11.5px; margin-bottom: 14px; }
.inspector-actions { display: flex; gap: 7px; margin-bottom: 14px; }
.inspector-section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.inspector-section h3 { margin-top: 0; }
.path-list { font-size: 10.5px; font-family: var(--mono); color: var(--dim);
overflow-wrap: anywhere; }
.inspector-player { width: calc(100% - 24px); margin: 12px; }
.anim-player { min-width: 0; }
.anim-stage { min-height: 235px; max-height: 58vh; display: flex; align-items: center;
justify-content: center; background-color: var(--panel-2); overflow: hidden; }
.anim-stage img { display: block; max-width: 100%; max-height: 58vh; object-fit: contain;
image-rendering: auto; }
.anim-toolbar { display: flex; align-items: center; gap: 7px; padding: 9px 10px;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--panel); }
.anim-toolbar .frame-slider { flex: 1; min-width: 60px; }
.anim-toolbar select { width: auto; padding: 5px 7px; font-size: 11px; }
.anim-play { width: 31px; height: 29px; padding: 0 !important; font-size: 15px !important; }
.anim-counter { min-width: 76px; color: var(--dim); font: 10.5px var(--mono);
text-align: right; white-space: nowrap; }
.anim-loop { display: flex; align-items: center; gap: 4px; color: var(--dim);
font-size: 10.5px; white-space: nowrap; cursor: pointer; }
.anim-events { padding: 12px 14px 14px; }
.anim-event-list { display: flex; flex-wrap: wrap; gap: 5px; max-height: 180px;
overflow: auto; scrollbar-width: thin; }
.anim-event { border: 1px solid var(--line); border-radius: 6px; padding: 4px 7px;
background: var(--bg); color: var(--ink); cursor: pointer; font: 10.5px var(--mono); }
.anim-event:hover { border-color: var(--accent); }
.anim-event.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.anim-event small { color: var(--dim); font: 9px system-ui, sans-serif; }
.anim-event-info { min-height: 18px; margin-top: 7px; color: var(--dim); font-size: 10.5px; }
.asset-inspector .anim-stage { border-radius: 10px 10px 0 0; }
.asset-inspector .anim-events { border-bottom: 1px solid var(--line); }
@media (max-width: 1180px) {
.asset-layout { grid-template-columns: 190px minmax(400px, 1fr) 290px; }
.asset-grid { grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)); }
}
@media (max-width: 900px) {
header { flex-wrap: wrap; }
#stats { display: none; }
nav { order: 3; width: 100%; overflow-x: auto; margin: 0; }
main { padding: 10px; }
.asset-layout { grid-template-columns: 1fr; }
.asset-sidebar { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.side-heading { grid-column: 1 / -1; }
.game-buttons { grid-column: 1 / -1; max-height: 150px; }
.asset-inspector { position: static; max-height: none; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -126,15 +256,66 @@ button.akcja:disabled { opacity: .45; cursor: default; }
<h1>rex<span>·</span>catalog</h1> <h1>rex<span>·</span>catalog</h1>
<div id="stats">wczytuję…</div> <div id="stats">wczytuję…</div>
<nav> <nav>
<button data-view="katalog" class="on">Katalog</button> <button data-view="zasoby" class="on">Zasoby</button>
<button data-view="skrypty">Skrypty</button> <button data-view="skrypty">Skrypty</button>
<button data-view="dzwiek">Dźwięk</button> <button data-view="dzwiek">Dźwięk</button>
<button data-view="formaty">Formaty</button> <button data-view="katalog">Kolekcja</button>
<button data-view="formaty">Statystyki</button>
</nav> </nav>
</header> </header>
<main> <main>
<section id="katalog" class="cols"> <section id="zasoby" class="asset-layout">
<aside class="asset-sidebar">
<div class="side-heading">Rodzaj zasobu</div>
<button class="side-button on" data-kind="visual"><span class="kind-dot"></span>
Grafika <span class="count" id="licz-grafika"></span></button>
<button class="side-button" data-kind="image"><span class="kind-dot"></span>
Obrazy <span class="count" id="licz-obrazy"></span></button>
<button class="side-button" data-kind="anim"><span class="kind-dot"></span>
Animacje <span class="count" id="licz-animacje"></span></button>
<button class="side-button" data-kind="audio"><span class="kind-dot"></span>
Nagrania <span class="count" id="licz-audio"></span></button>
<div class="side-heading">Gra</div>
<div class="game-buttons" id="gryzasobow">
<button class="side-button on" data-edition=""><span>Wszystkie gry</span></button>
</div>
</aside>
<div class="asset-browser">
<div class="asset-toolbar">
<div class="asset-title-row">
<h2 id="tytulzasobow">Wszystkie zasoby</h2>
<span class="result-count" id="licznikzasobow">wczytuję…</span>
</div>
<div class="asset-controls">
<div class="search-wrap">
<input type="search" id="szukajzasobow" placeholder="Szukaj po nazwie lub ścieżce…"
autocomplete="off">
</div>
<select id="sortzasobow" aria-label="Sortowanie zasobów">
<option value="name">według nazwy</option>
<option value="game">według gry</option>
<option value="largest">największe</option>
</select>
</div>
</div>
<div class="asset-content" id="przewijakzasobow">
<div class="asset-grid" id="siatkazasobow"></div>
<div class="asset-more" id="wiecejzasobow"></div>
</div>
</div>
<aside class="asset-inspector" id="inspektorzasobu">
<div class="inspector-empty">
<div><div style="font-size:30px;margin-bottom:8px"></div>
Wybierz zasób, żeby zobaczyć podgląd, parametry i miejsca użycia.</div>
</div>
</aside>
</section>
<section id="katalog" hidden class="cols">
<div> <div>
<div class="card"> <div class="card">
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off"> <input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
@@ -185,8 +366,10 @@ button.akcja:disabled { opacity: .45; cursor: default; }
</section> </section>
<section id="formaty" hidden> <section id="formaty" hidden>
<div class="card" style="max-width:620px"> <div class="card" style="max-width:720px">
<h2>Rozkład formatów</h2> <h2>Statystyki kolekcji</h2>
<div id="pelneStatystyki" class="dim" style="font-size:12.5px;margin:7px 0 18px"></div>
<h3>Rozkład formatów</h3>
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead> <table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
<tbody id="tabformaty"></tbody></table> <tbody id="tabformaty"></tbody></table>
</div> </div>
@@ -210,10 +393,291 @@ let tytuly = [];
// ---- nawigacja ---- // ---- nawigacja ----
document.querySelectorAll('nav button').forEach(b => b.onclick = () => { document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
if (b.dataset.view !== 'zasoby') zatrzymajWszystkieAnimacje();
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b)); document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
['katalog','skrypty','dzwiek','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view); ['zasoby','katalog','skrypty','dzwiek','formaty'].forEach(v =>
$('#'+v).hidden = v !== b.dataset.view);
}); });
// ---- zasoby ----
const stanZasobow = {
kind: 'visual', edition: null, q: '', sort: 'name', offset: 0, total: 0,
loading: false, selected: null, items: new Map()
};
const nazwyRodzajow = {
visual: 'Grafika', image: 'Obrazy', anim: 'Animacje', audio: 'Nagrania'
};
let czekajZasoby;
function rysujGryZasobow() {
$('#gryzasobow').innerHTML = `
<button class="side-button ${stanZasobow.edition == null ? 'on' : ''}" data-edition="">
<span>Wszystkie gry</span></button>` +
tytuly.map(t => `<button class="side-button ${stanZasobow.edition == t.edition_id ? 'on' : ''}"
data-edition="${t.edition_id}" title="${esc(t.title)}">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
</button>`).join('');
$('#gryzasobow').querySelectorAll('[data-edition]').forEach(b => b.onclick = () => {
stanZasobow.edition = b.dataset.edition ? +b.dataset.edition : null;
$('#gryzasobow').querySelectorAll('.side-button').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
}
function aktualizujTytulZasobow() {
const gra = tytuly.find(t => t.edition_id === stanZasobow.edition);
$('#tytulzasobow').textContent = gra ? gra.title : nazwyRodzajow[stanZasobow.kind];
}
function opisKarty(a) {
if (a.kind === 'audio') return czas(a.duration_ms);
if (a.kind === 'anim') return `${a.frames || 1} kl. · ${a.width || '?'}×${a.height || '?'}`;
return `${a.width || '?'}×${a.height || '?'}`;
}
const aktywneOdtwarzacze = new Set();
function htmlOdtwarzaczaAnimacji(m, sha1, pokazZdarzenia = true) {
return `<div class="anim-player" data-animation="${sha1}">
<div class="anim-stage kratka"><img src="/api/render/${sha1}?frame=0" alt=""></div>
<div class="anim-toolbar">
<button class="akcja anim-play" data-anim-play title="Odtwórz lub zatrzymaj">▶</button>
<input class="frame-slider" data-anim-slider type="range" min="0"
max="${Math.max(0, m.frames - 1)}" value="0" aria-label="Klatka animacji">
<span class="anim-counter" data-anim-counter>1 / ${m.frames}</span>
<select data-anim-speed title="Prędkość odtwarzania">
<option value="0.5">½×</option><option value="1" selected>1×</option>
<option value="2">2×</option>
</select>
<label class="anim-loop"><input type="checkbox" data-anim-loop checked> pętla</label>
</div>
${pokazZdarzenia && m.events?.length ? `<div class="anim-events">
<h3 style="margin-top:0">Zdarzenia (${m.events.length})</h3>
<div class="anim-event-list">
<button class="anim-event on" data-anim-event="">Wszystkie klatki</button>
${m.events.map((e, i) => `<button class="anim-event" data-anim-event="${i}">
${esc(e.name)} <small>${e.frames_count}</small></button>`).join('')}
</div>
<div class="anim-event-info" data-anim-event-info>
Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.
</div>
</div>` : ''}
</div>`;
}
function zatrzymajOdtwarzacz(root) {
const controller = root?._animController;
if (controller) controller.stop();
}
function podlaczOdtwarzacz(root, m, sha1) {
if (!root) return;
zatrzymajOdtwarzacz(root);
const image = root.querySelector('.anim-stage img');
const play = root.querySelector('[data-anim-play]');
const slider = root.querySelector('[data-anim-slider]');
const counter = root.querySelector('[data-anim-counter]');
const speed = root.querySelector('[data-anim-speed]');
const loop = root.querySelector('[data-anim-loop]');
const eventInfo = root.querySelector('[data-anim-event-info]');
let sequence = Array.from({length: m.frames}, (_, i) => i);
let event = null, position = 0, playing = false, timer = null;
const controller = {
stop() {
playing = false;
clearTimeout(timer);
play.textContent = '▶';
aktywneOdtwarzacze.delete(controller);
}
};
root._animController = controller;
const pokaz = () => {
if (!sequence.length) return;
position = Math.max(0, Math.min(position, sequence.length - 1));
const frame = Math.max(0, Math.min(sequence[position], m.frames - 1));
image.src = `/api/render/${sha1}?frame=${frame}`;
slider.max = Math.max(0, sequence.length - 1);
slider.value = position;
counter.textContent = `${position + 1} / ${sequence.length} · #${frame + 1}`;
};
const nastepna = () => {
if (!playing) return;
let next = position + 1;
if (loop.checked && event && event.loop_end > 0 && next > event.loop_end) {
next = Math.max(0, Math.min(event.loop_start, sequence.length - 1));
} else if (next >= sequence.length) {
if (loop.checked) next = 0;
else { controller.stop(); return; }
}
position = next;
pokaz();
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
const start = () => {
if (playing || sequence.length < 2) return;
if (position >= sequence.length - 1 && !loop.checked) position = 0;
playing = true;
play.textContent = '❚❚';
aktywneOdtwarzacze.add(controller);
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
play.onclick = () => playing ? controller.stop() : start();
slider.oninput = () => { position = +slider.value; pokaz(); };
root.querySelectorAll('[data-anim-event]').forEach(button => button.onclick = () => {
controller.stop();
root.querySelectorAll('[data-anim-event]').forEach(x => x.classList.toggle('on', x === button));
event = button.dataset.animEvent === '' ? null : m.events[+button.dataset.animEvent];
sequence = event?.frames?.length
? event.frames.slice()
: event
? Array.from({length: Math.min(event.frames_count, m.frames)}, (_, i) => i)
: Array.from({length: m.frames}, (_, i) => i);
position = 0;
loop.checked = !event || event.loop_end > 0;
if (eventInfo) eventInfo.innerHTML = event
? `<span class="mono">${esc(event.name)}</span> · ${event.frames_count} kroków`
+ (event.loop_end > 0
? ` · pętla ${event.loop_start + 1}${event.loop_end + 1}`
: ' · bez pętli')
: `Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.`;
pokaz();
start();
});
pokaz();
}
function zatrzymajWszystkieAnimacje() {
[...aktywneOdtwarzacze].forEach(controller => controller.stop());
}
function kartaZasobu(a) {
const podglad = a.kind === 'audio'
? '<div class="asset-audio-art" aria-hidden="true">♪</div>'
: `<img class="kratka" loading="lazy" src="/api/preview/${a.sha1}" alt="">`;
return `<button class="asset-card ${stanZasobow.selected === a.sha1 ? 'on' : ''}"
data-asset="${a.sha1}" title="${esc(a.path)}">
<div class="asset-thumb">${podglad}</div>
<div class="asset-card-body">
<div class="asset-name">${esc(a.name || a.path)}</div>
<div class="asset-game">${esc(a.title || a.copy || 'bez przypisanej gry')}</div>
<div class="asset-badges">
<span class="asset-badge">${esc(a.codec || a.kind)}</span>
<span class="asset-badge ${a.kind === 'anim' ? 'frames' : ''}">${opisKarty(a)}</span>
${a.places_count > 1 ? `<span class="asset-badge" title="${a.places_count} wystąpień">×${a.places_count}</span>` : ''}
</div>
</div>
</button>`;
}
async function ladujZasoby(reset) {
if (stanZasobow.loading) return;
stanZasobow.loading = true;
if (reset) {
stanZasobow.offset = 0;
stanZasobow.items.clear();
$('#siatkazasobow').innerHTML = Array.from({length: 8}, () => '<div class="skeleton"></div>').join('');
$('#wiecejzasobow').innerHTML = '';
$('#licznikzasobow').textContent = 'wczytuję…';
}
try {
const p = new URLSearchParams({kind: stanZasobow.kind, q: stanZasobow.q,
sort: stanZasobow.sort, offset: stanZasobow.offset, limit: 80});
if (stanZasobow.edition != null) p.set('edition', stanZasobow.edition);
const r = await get('/api/assets?' + p);
if (reset) $('#siatkazasobow').innerHTML = '';
r.items.forEach(a => stanZasobow.items.set(a.sha1, a));
$('#siatkazasobow').insertAdjacentHTML('beforeend', r.items.map(kartaZasobu).join(''));
$('#siatkazasobow').querySelectorAll('[data-asset]').forEach(k =>
k.onclick = () => pokazZasobWInspektor(k.dataset.asset));
stanZasobow.offset += r.items.length;
stanZasobow.total = r.total;
$('#licznikzasobow').textContent = `${r.total.toLocaleString('pl')} zasobów`;
if (!r.items.length && reset) {
$('#siatkazasobow').innerHTML = '<div class="empty" style="grid-column:1/-1">Nic nie pasuje do filtrów.</div>';
}
$('#wiecejzasobow').innerHTML = r.has_more
? '<button class="akcja" id="wiecej">Pokaż więcej</button>' : '';
if ($('#wiecej')) $('#wiecej').onclick = () => ladujZasoby(false);
} catch (e) {
$('#siatkazasobow').innerHTML = `<div class="empty" style="grid-column:1/-1">Błąd: ${esc(e.message)}</div>`;
} finally {
stanZasobow.loading = false;
}
}
async function pokazZasobWInspektor(sha1) {
zatrzymajOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'));
stanZasobow.selected = sha1;
$('#siatkazasobow').querySelectorAll('.asset-card').forEach(k =>
k.classList.toggle('on', k.dataset.asset === sha1));
const a = stanZasobow.items.get(sha1) || {};
$('#inspektorzasobu').innerHTML = '<div class="inspector-empty">Wczytuję szczegóły…</div>';
try {
const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim', audio = m.kind === 'audio';
const hero = audio
? `<audio class="inspector-player" controls autoplay preload="metadata" src="/api/audio/${sha1}"></audio>`
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
: `<div class="inspector-hero kratka"><img src="/api/render/${sha1}" alt=""></div>`;
const d = m.dialogue?.[0] || {};
$('#inspektorzasobu').innerHTML = `${hero}<div class="inspector-body">
<h2>${esc(a.name || m.places?.[0]?.path || sha1)}</h2>
<div class="inspector-sub">${esc(a.title || a.copy || '')}</div>
${!audio ? `<div class="inspector-actions">
<button class="akcja" id="pelnyPodglad">Pełny podgląd</button>
</div>` : ''}
<dl>
<dt>Rodzaj</dt><dd>${m.kind === 'anim' ? 'animacja' : m.kind === 'image' ? 'obraz' : 'nagranie'}</dd>
<dt>Format</dt><dd>${esc(m.codec || '—')}</dd>
${audio ? `<dt>Długość</dt><dd>${czas(m.duration_ms)}</dd>
<dt>Dźwięk</dt><dd>${m.sample_rate || '—'} Hz · ${m.channels === 1 ? 'mono' : m.channels + ' kanały'}</dd>`
: `<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>`}
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
${d.speaker ? `<dt>Mówi</dt><dd>${esc(d.speaker)}</dd>` : ''}
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
</dl>
${m.transcript ? `<div class="inspector-section"><h3>Co słychać</h3>
<div>${esc(m.transcript)}</div><div class="maszyna">rozpoznane maszynowo</div></div>` : ''}
${m.used_by?.length ? `<div class="inspector-section"><h3>Używane w skryptach (${m.used_by.length})</h3>
${m.used_by.slice(0, 10).map(u => `<div style="padding:2px 0;font-size:12px">
<a href="#" data-skrypt="${u.script_sha1}" class="mono">${esc(u.object)}</a>
<span class="dim">:${esc(u.field)}</span></div>`).join('')}</div>` : ''}
<div class="inspector-section"><h3>Miejsca (${m.places?.length || 0})</h3>
<div class="path-list">${(m.places || []).slice(0, 12).map(p =>
`${esc(p.path)}<br><span class="dim">${esc(p.copy)}</span>`).join('<br><br>')}</div></div>
<div class="inspector-section"><div class="mono dim" style="font-size:9.5px">sha1 ${esc(sha1)}</div></div>
</div>`;
if ($('#pelnyPodglad')) $('#pelnyPodglad').onclick = () => otworzZasob(sha1);
if (anim) podlaczOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'), m, sha1);
$('#inspektorzasobu').querySelectorAll('[data-skrypt]').forEach(link =>
link.onclick = e => { e.preventDefault(); otworzSkrypt(link.dataset.skrypt); });
} catch (e) {
$('#inspektorzasobu').innerHTML = `<div class="inspector-empty">Nie udało się wczytać zasobu:<br>${esc(e.message)}</div>`;
}
}
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => {
stanZasobow.kind = b.dataset.kind;
document.querySelectorAll('[data-kind]').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
$('#szukajzasobow').oninput = e => {
clearTimeout(czekajZasoby);
stanZasobow.q = e.target.value.trim();
czekajZasoby = setTimeout(() => ladujZasoby(true), 250);
};
$('#sortzasobow').onchange = e => { stanZasobow.sort = e.target.value; ladujZasoby(true); };
// ---- katalog ---- // ---- katalog ----
function rysujTytuly(filtr = '') { function rysujTytuly(filtr = '') {
const f = filtr.toLowerCase(); const f = filtr.toLowerCase();
@@ -529,12 +993,9 @@ async function otworzNagranie(sha1) {
} }
// ---- podgląd grafiki ---- // ---- podgląd grafiki ----
let klatki = null;
async function otworzZasob(sha1) { async function otworzZasob(sha1) {
const m = await get('/api/media/' + sha1); const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim'; const anim = m.kind === 'anim';
klatki = anim ? {sha1, ile: m.frames, teraz: 0} : null;
$('#zaslona').innerHTML = `<div> $('#zaslona').innerHTML = `<div>
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px"> <div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
@@ -544,13 +1005,8 @@ async function otworzZasob(sha1) {
</div> </div>
${m.note && m.note.startsWith('nieczytelny') ${m.note && m.note.startsWith('nieczytelny')
? `<div class="empty">${esc(m.note)}</div>` ? `<div class="empty">${esc(m.note)}</div>`
: `<img class="duzy kratka" src="/api/render/${sha1}${anim ? '?frame=0' : ''}" alt="">`} : anim ? htmlOdtwarzaczaAnimacji(m, sha1)
${anim && m.frames > 1 ? `<div class="postep" style="margin-top:12px"> : `<img class="duzy kratka" src="/api/render/${sha1}" alt="">`}
<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"> <dl style="margin-top:12px">
<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd> <dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''} ${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
@@ -559,12 +1015,6 @@ async function otworzZasob(sha1) {
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''} ${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd> <dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
</dl> </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> ${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) <div class="dim" style="font-size:12px">${m.used_by.slice(0, 10)
.map(u => esc(u.object)).join(', ')}</div>` : ''} .map(u => esc(u.object)).join(', ')}</div>` : ''}
@@ -573,34 +1023,27 @@ async function otworzZasob(sha1) {
$('#zamknij').onclick = zamknijZaslone; $('#zamknij').onclick = zamknijZaslone;
$('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); }; $('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); };
if (anim) podlaczOdtwarzacz($('#zaslona').querySelector('.anim-player'), m, sha1);
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() { function zamknijZaslone() {
zatrzymajOdtwarzacz($('#zaslona').querySelector('.anim-player'));
$('#zaslona').hidden = true; $('#zaslona').hidden = true;
$('#zaslona').innerHTML = ''; $('#zaslona').innerHTML = '';
klatki = null;
} }
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if ($('#zaslona').hidden) return; if ($('#zaslona').hidden) return;
if (e.key === 'Escape') zamknijZaslone(); if (e.key === 'Escape') zamknijZaslone();
if (klatki && $('#suwak')) { const slider = $('#zaslona').querySelector('[data-anim-slider]');
if (e.key === 'ArrowLeft') $('#poprz').click(); if (slider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
if (e.key === 'ArrowRight') $('#nast').click(); slider.value = Math.max(0, Math.min(+slider.max,
+slider.value + (e.key === 'ArrowLeft' ? -1 : 1)));
slider.dispatchEvent(new Event('input'));
}
if (slider && e.key === ' ' && !['INPUT','SELECT','BUTTON'].includes(e.target.tagName)) {
e.preventDefault();
$('#zaslona').querySelector('[data-anim-play]').click();
} }
}); });
@@ -663,12 +1106,17 @@ function sledzPostep() {
(async () => { (async () => {
const s = await get('/api/stats'); const s = await get('/api/stats');
$('#stats').textContent = $('#stats').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików ` `${s.files.toLocaleString('pl')} plików · ${s.copies} kopii · ${mb(s.bytes)}`;
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów ` $('#licz-grafika').textContent = (s.images + s.animations).toLocaleString('pl');
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) ` $('#licz-obrazy').textContent = s.images.toLocaleString('pl');
+ `· ${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji ` $('#licz-animacje').textContent = s.animations.toLocaleString('pl');
+ `(${s.anim_frames.toLocaleString('pl')} klatek) ` $('#licz-audio').textContent = s.audio.toLocaleString('pl');
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`; $('#pelneStatystyki').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików · `
+ `${s.blobs.toLocaleString('pl')} unikalnych treści · ${s.scripts.toLocaleString('pl')} skryptów · `
+ `${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) · `
+ `${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
+ `(${s.anim_frames.toLocaleString('pl')} klatek) · ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n)); const max = Math.max(...s.formats.map(f => f.n));
$('#tabformaty').innerHTML = s.formats.map(f => ` $('#tabformaty').innerHTML = s.formats.map(f => `
@@ -683,6 +1131,9 @@ function sledzPostep() {
tytuly = await get('/api/titles'); tytuly = await get('/api/titles');
rysujTytuly(); rysujTytuly();
rysujGryZasobow();
aktualizujTytulZasobow();
await ladujZasoby(true);
$('#filtr').oninput = e => rysujTytuly(e.target.value); $('#filtr').oninput = e => rysujTytuly(e.target.value);
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message); })().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
</script> </script>