diff --git a/editor/backend/src/main/java/com/engine/editor/controller/dto/DecisionItemRequest.java b/editor/backend/src/main/java/com/engine/editor/controller/dto/DecisionItemRequest.java index daabcab..0a74ba2 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/dto/DecisionItemRequest.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/dto/DecisionItemRequest.java @@ -1,3 +1,9 @@ package com.engine.editor.controller.dto; -public record DecisionItemRequest(String decisionKey, Boolean isDefault, Integer decisionOrder) {} +public record DecisionItemRequest( + String decisionKey, + Boolean isDefault, + Integer decisionOrder, + String keyboardKey, + String conditionExpression +) {} diff --git a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateProjectConfigRequest.java b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateProjectConfigRequest.java index e03c2d7..0d9f88e 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateProjectConfigRequest.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateProjectConfigRequest.java @@ -14,6 +14,8 @@ public record UpdateProjectConfigRequest( Double decisionTimeoutSecs, String defaultLocaleCode, String defaultBackgroundColor, + Boolean hideDecisionButtons, + Boolean showDecisionInputIndicator, Boolean ffmpegThreadsAuto, Integer ffmpegThreads ) {} diff --git a/editor/backend/src/main/java/com/engine/editor/controller/dto/VideoLayerRequest.java b/editor/backend/src/main/java/com/engine/editor/controller/dto/VideoLayerRequest.java index b7f2467..8d3cb3e 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/dto/VideoLayerRequest.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/dto/VideoLayerRequest.java @@ -1,3 +1,3 @@ package com.engine.editor.controller.dto; -public record VideoLayerRequest(String assetId, Double startAt, Integer startAtFrames, Boolean freezeLastFrame) {} +public record VideoLayerRequest(String assetId, Double startAt, Integer startAtFrames, Boolean freezeLastFrame, Boolean loopLayer) {} diff --git a/editor/backend/src/main/java/com/engine/editor/ffmpeg/FFmpegVideoProcessor.java b/editor/backend/src/main/java/com/engine/editor/ffmpeg/FFmpegVideoProcessor.java index cf358ae..a1a57d7 100644 --- a/editor/backend/src/main/java/com/engine/editor/ffmpeg/FFmpegVideoProcessor.java +++ b/editor/backend/src/main/java/com/engine/editor/ffmpeg/FFmpegVideoProcessor.java @@ -200,6 +200,10 @@ private List buildCompositeCommand(CompositeSpec spec) { List layers = spec.getVideoLayers(); for (VideoLayerSpec layer : layers) { List preOpts = new ArrayList<>(); + if (layer.isLoopLayer()) { + preOpts.add("-stream_loop"); + preOpts.add("-1"); + } if (layer.isHasAlpha() && "vp9".equalsIgnoreCase(layer.getCodec())) { preOpts.add("-vcodec"); preOpts.add("libvpx-vp9"); @@ -227,7 +231,7 @@ private List buildCompositeCommand(CompositeSpec spec) { } } - String filterComplex = buildOverlayFilter(layers, tracks); + String filterComplex = buildOverlayFilter(layers, tracks, spec.getOutputResolution()); if (filterComplex != null) { builder.filterComplex(filterComplex); builder.mapVideo("[vout]"); @@ -255,17 +259,29 @@ private List buildCompositeCommand(CompositeSpec spec) { * * Returns {@code null} when there are no layers and no tracks (nothing to filter). */ - private String buildOverlayFilter(List layers, List tracks) { + private String buildOverlayFilter(List layers, List tracks, + String outputResolution) { if (layers.isEmpty() && tracks.isEmpty()) return null; List parts = new ArrayList<>(); String currentVideo = "[0:v]"; + String[] canvasSize = parseResolution(outputResolution); + String canvasWidth = canvasSize != null ? canvasSize[0] : null; + String canvasHeight = canvasSize != null ? canvasSize[1] : null; // input 0 is the background; layers start at input index 1 for (int i = 0; i < layers.size(); i++) { int inputIdx = i + 1; String nextLabel = (i == layers.size() - 1) ? "[vout]" : "[ov" + i + "]"; String eofAction = layers.get(i).isFreezeLastFrame() ? "repeat" : "pass"; - parts.add(currentVideo + "[" + inputIdx + ":v]overlay=0:0:format=auto:eof_action=" + eofAction + nextLabel); + String overlayInput = "[" + inputIdx + ":v]"; + if (canvasWidth != null && canvasHeight != null) { + String scaledLabel = "[vs" + i + "]"; + parts.add("[" + inputIdx + ":v]scale=w=" + canvasWidth + ":h=" + canvasHeight + + ":force_original_aspect_ratio=decrease,setsar=1" + scaledLabel); + overlayInput = scaledLabel; + } + parts.add(currentVideo + overlayInput + + "overlay=(W-w)/2:(H-h)/2:format=auto:eof_action=" + eofAction + nextLabel); currentVideo = nextLabel; } @@ -287,4 +303,14 @@ private String buildOverlayFilter(List layers, List referencedMusicAssetPaths = collectReferencedMusicAssetPaths(sceneNodes); + Path zipPath = buildPackage(job, projectDir, outputBase, manifestFile, sourceAssetsDir, referencedMusicAssetPaths); lastZipPath = zipPath; job.markDone(zipPath.toAbsolutePath().toString()); @@ -221,7 +224,8 @@ private void runCompilation(PreviewJob job) { // ── Packaging ───────────────────────────────────────────────────────────── private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, - Path manifestFile) throws Exception { + Path manifestFile, Path sourceAssetsDir, + Set referencedMusicAssetPaths) throws Exception { Path distDir = projectDir.resolve("dist"); Files.createDirectories(distDir); @@ -279,11 +283,32 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, } } + // Copy compiled HLS output into dist/output so dist/ can be run directly. + job.setProgress(96, "Packaging: copying runtime media…"); + Path distOutputDir = distDir.resolve("output"); + copyDirectoryContents(outputBase, distOutputDir); + + // Copy only referenced background-music assets into dist/assets. + if (Files.isDirectory(sourceAssetsDir) && referencedMusicAssetPaths != null && !referencedMusicAssetPaths.isEmpty()) { + Path distAssetsDir = distDir.resolve("assets"); + for (String relPath : referencedMusicAssetPaths) { + Path assetPath = sourceAssetsDir.resolve(relPath).normalize(); + if (!assetPath.startsWith(sourceAssetsDir) || !Files.isRegularFile(assetPath)) { + continue; + } + Path distAssetPath = distAssetsDir.resolve(relPath).normalize(); + if (!distAssetPath.startsWith(distAssetsDir)) { + continue; + } + Files.createDirectories(distAssetPath.getParent()); + Files.copy(assetPath, distAssetPath, StandardCopyOption.REPLACE_EXISTING); + } + } + // Create dist.zip job.setProgress(97, "Packaging: creating ZIP archive…"); Path zipFile = projectDir.resolve("dist.zip"); - Path assetsDir = projectDir.resolve("assets"); - createZip(distDir, outputBase, assetsDir, zipFile); + createZip(distDir, outputBase, sourceAssetsDir, referencedMusicAssetPaths, zipFile); job.setProgress(100, "Package ready."); return zipFile; @@ -315,7 +340,27 @@ private String buildReadme(String projectName) { "Delete it to reset to the beginning.\n"; } - private void createZip(Path distDir, Path outputBase, Path assetsDir, Path zipFile) throws IOException { + private void copyDirectoryContents(Path sourceDir, Path targetDir) throws IOException { + if (!Files.isDirectory(sourceDir)) { + return; + } + Files.createDirectories(targetDir); + try (var walk = Files.walk(sourceDir)) { + for (Path sourcePath : walk.filter(Files::isRegularFile).toList()) { + Path relative = sourceDir.relativize(sourcePath); + Path targetPath = targetDir.resolve(relative).normalize(); + if (!targetPath.startsWith(targetDir)) { + continue; + } + Files.createDirectories(targetPath.getParent()); + Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private void createZip(Path distDir, Path outputBase, Path assetsDir, + Set referencedMusicAssetPaths, + Path zipFile) throws IOException { try (ZipOutputStream zos = new ZipOutputStream( Files.newOutputStream(zipFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) { @@ -346,29 +391,35 @@ private void createZip(Path distDir, Path outputBase, Path assetsDir, Path zipFi } } - // Add assets/ (audio files for background music) under game/assets/ - if (Files.isDirectory(assetsDir)) { - try (var walk = Files.walk(assetsDir)) { - walk.filter(Files::isRegularFile) - .filter(p -> { - String fn = p.getFileName().toString().toLowerCase(); - return fn.endsWith(".mp3") || fn.endsWith(".ogg") || - fn.endsWith(".wav") || fn.endsWith(".aac") || - fn.endsWith(".flac"); - }) - .forEach(p -> { - String name = "game/assets/" + assetsDir.relativize(p).toString(); - try { - zos.putNextEntry(new ZipEntry(name)); - Files.copy(p, zos); - zos.closeEntry(); - } catch (IOException e) { throw new RuntimeException(e); } - }); + // Add only referenced background-music assets under game/assets/ + if (Files.isDirectory(assetsDir) && referencedMusicAssetPaths != null && !referencedMusicAssetPaths.isEmpty()) { + for (String relPath : referencedMusicAssetPaths) { + Path assetPath = assetsDir.resolve(relPath).normalize(); + if (!assetPath.startsWith(assetsDir) || !Files.isRegularFile(assetPath)) { + continue; + } + String name = "game/assets/" + relPath.replace('\\', '/'); + try { + zos.putNextEntry(new ZipEntry(name)); + Files.copy(assetPath, zos); + zos.closeEntry(); + } catch (IOException e) { throw new RuntimeException(e); } } } } } + private Set collectReferencedMusicAssetPaths(List> sceneNodes) { + return sceneNodes.stream() + .map(node -> node.get("musicAssetRelPath")) + .filter(String.class::isInstance) + .map(String.class::cast) + .map(String::trim) + .filter(path -> !path.isBlank()) + .map(path -> path.replace('\\', '/')) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + // ── Scene compilation ───────────────────────────────────────────────────── @SuppressWarnings("unchecked") @@ -389,10 +440,11 @@ private void compileScene(Map node, ProjectConfigData config, Map r = layerData.get(i); String filePath = absPath((String) r.get("assetRelPath"), r); if (filePath == null) continue; - boolean hasAlpha = r.get("hasAlpha") instanceof Boolean b ? b : Boolean.TRUE.equals(r.get("hasAlpha")); - boolean freeze = r.get("freezeLastFrame") instanceof Boolean b ? b : Boolean.TRUE.equals(r.get("freezeLastFrame")); + boolean hasAlpha = r.get("hasAlpha") instanceof Boolean b ? b : Boolean.TRUE.equals(r.get("hasAlpha")); + boolean freeze = r.get("freezeLastFrame") instanceof Boolean b ? b : Boolean.TRUE.equals(r.get("freezeLastFrame")); + boolean loopLayer = r.get("loopLayer") instanceof Boolean b ? b : Boolean.TRUE.equals(r.get("loopLayer")); String codec = r.get("codec") instanceof String s ? s : null; - layers.add(new VideoLayerSpec(Path.of(filePath), toDouble(r.get("startAt")), i, hasAlpha, freeze, codec)); + layers.add(new VideoLayerSpec(Path.of(filePath), toDouble(r.get("startAt")), i, hasAlpha, freeze, codec, loopLayer)); } List tracks = new ArrayList<>(); diff --git a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java index 9545648..cfd2308 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java @@ -165,7 +165,7 @@ private void fillSceneNode(Map n, String nodeId, // Video layers with relative asset path List> layers = jdbc.queryForList(""" - SELECT nvl.layer_order, nvl.start_at, nvl.start_at_frames, nvl.freeze_last_frame, + SELECT nvl.layer_order, nvl.start_at, nvl.start_at_frames, nvl.freeze_last_frame, nvl.loop_layer, a.id AS asset_id, a.file_path, a.file_name, a.has_alpha, a.codec, a.duration FROM node_video_layers nvl JOIN assets a ON a.id=nvl.asset_id WHERE nvl.node_id=? ORDER BY nvl.layer_order @@ -181,6 +181,7 @@ private void fillSceneNode(Map n, String nodeId, l.put("hasAlpha", intFlag(r.get("has_alpha"))); l.put("codec", r.get("codec")); l.put("freezeLastFrame", intFlag(r.get("freeze_last_frame"))); + l.put("loopLayer", intFlag(r.get("loop_layer"))); l.put("duration", r.get("duration")); l.put("startAtFrames", r.get("start_at_frames")); l.put("startAt", resolveStartAt(r.get("start_at"), r.get("start_at_frames"), fps)); @@ -210,7 +211,7 @@ private void fillSceneNode(Map n, String nodeId, // Decisions List> decs = jdbc.queryForList(""" - SELECT decision_key, is_default, decision_order + SELECT decision_key, is_default, decision_order, keyboard_key, condition_expression FROM scene_decisions WHERE node_id=? ORDER BY decision_order """, nodeId); List> decList = new ArrayList<>(); @@ -219,6 +220,8 @@ private void fillSceneNode(Map n, String nodeId, d.put("decisionKey", r.get("decision_key")); d.put("isDefault", intFlag(r.get("is_default"))); d.put("decisionOrder", r.get("decision_order")); + d.put("keyboardKey", r.get("keyboard_key")); + d.put("conditionExpression", r.get("condition_expression")); decList.add(d); } n.put("decisions", decList); @@ -406,6 +409,8 @@ private Map buildProjectSection(ProjectConfigData c) { p.put("decisionTimeoutSecs", c.getDecisionTimeoutSecs()); p.put("defaultLocaleCode", c.getDefaultLocaleCode()); p.put("defaultBackgroundColor", c.getDefaultBackgroundColor()); + p.put("hideDecisionButtons", c.isHideDecisionButtons()); + p.put("showDecisionInputIndicator", c.isShowDecisionInputIndicator()); return p; } @@ -431,8 +436,9 @@ private boolean intFlag(Object val) { private double computeSceneDuration(List> layers, List> audios) { - double max = 0; + double max = -1; for (Map l : layers) { + if (Boolean.TRUE.equals(l.get("loopLayer"))) continue; // looped layers fill the scene, not determine its duration Object dur = l.get("duration"); Object start = l.get("startAt"); if (dur != null) max = Math.max(max, toDouble(dur) + toDouble(start)); @@ -442,6 +448,14 @@ private double computeSceneDuration(List> layers, Object start = t.get("startAt"); if (dur != null) max = Math.max(max, toDouble(dur) + toDouble(start)); } + if (max < 0) { + for (Map l : layers) { + Object dur = l.get("duration"); + Object start = l.get("startAt"); + if (dur != null) max = Math.max(max, toDouble(dur) + toDouble(start)); + } + } + if (max < 0) return 0; return max; } diff --git a/editor/backend/src/main/java/com/engine/editor/service/ProjectService.java b/editor/backend/src/main/java/com/engine/editor/service/ProjectService.java index 56c7215..c6735c2 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ProjectService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ProjectService.java @@ -100,6 +100,8 @@ public synchronized ProjectConfigData createProject(CreateProjectRequest req) { config.setAudioBitRate(req.audioBitRate() != null ? req.audioBitRate() : 128); config.setDecisionTimeoutSecs(req.decisionTimeoutSecs() != null ? req.decisionTimeoutSecs() : 5.0); config.setDefaultBackgroundColor(req.defaultBackgroundColor() != null ? req.defaultBackgroundColor() : "#000000"); + config.setHideDecisionButtons(false); + config.setShowDecisionInputIndicator(false); config.setFfmpegThreads(req.ffmpegThreads()); // null = Auto insertConfig(config); @@ -171,6 +173,8 @@ public ProjectConfigData updateConfig(UpdateProjectConfigRequest req) { if (req.decisionTimeoutSecs() != null) currentConfig.setDecisionTimeoutSecs(req.decisionTimeoutSecs()); if (req.defaultLocaleCode() != null) currentConfig.setDefaultLocaleCode(req.defaultLocaleCode()); if (req.defaultBackgroundColor() != null) currentConfig.setDefaultBackgroundColor(req.defaultBackgroundColor()); + if (req.hideDecisionButtons() != null) currentConfig.setHideDecisionButtons(req.hideDecisionButtons()); + if (req.showDecisionInputIndicator() != null) currentConfig.setShowDecisionInputIndicator(req.showDecisionInputIndicator()); if (Boolean.TRUE.equals(req.ffmpegThreadsAuto())) { currentConfig.setFfmpegThreads(null); } else if (req.ffmpegThreads() != null) { @@ -242,8 +246,8 @@ private void insertConfig(ProjectConfigData config) { (id, name, assets_directory, output_directory, preview_resolution, compile_resolutions, fps, audio_sample_rate, audio_bit_rate, decision_timeout_secs, default_locale_code, default_background_color, - ffmpeg_threads) - VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + hide_decision_buttons, show_decision_input_indicator, ffmpeg_threads) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, config.getName(), config.getAssetsDirectory(), @@ -256,6 +260,8 @@ private void insertConfig(ProjectConfigData config) { config.getDecisionTimeoutSecs(), config.getDefaultLocaleCode(), config.getDefaultBackgroundColor(), + config.isHideDecisionButtons() ? 1 : 0, + config.isShowDecisionInputIndicator() ? 1 : 0, config.getFfmpegThreads() ); } @@ -267,7 +273,8 @@ private void saveConfig(ProjectConfigData config) { preview_resolution = ?, compile_resolutions = ?, fps = ?, audio_sample_rate = ?, audio_bit_rate = ?, decision_timeout_secs = ?, default_locale_code = ?, - default_background_color = ?, ffmpeg_threads = ? + default_background_color = ?, hide_decision_buttons = ?, + show_decision_input_indicator = ?, ffmpeg_threads = ? WHERE id = 1 """, config.getName(), @@ -281,6 +288,8 @@ private void saveConfig(ProjectConfigData config) { config.getDecisionTimeoutSecs(), config.getDefaultLocaleCode(), config.getDefaultBackgroundColor(), + config.isHideDecisionButtons() ? 1 : 0, + config.isShowDecisionInputIndicator() ? 1 : 0, config.getFfmpegThreads() ); } @@ -301,6 +310,8 @@ private ProjectConfigData loadConfig() { c.setDecisionTimeoutSecs(rs.getDouble("decision_timeout_secs")); c.setDefaultLocaleCode(rs.getString("default_locale_code")); c.setDefaultBackgroundColor(rs.getString("default_background_color")); + c.setHideDecisionButtons(rs.getInt("hide_decision_buttons") == 1); + c.setShowDecisionInputIndicator(rs.getInt("show_decision_input_indicator") == 1); int threads = rs.getInt("ffmpeg_threads"); c.setFfmpegThreads(rs.wasNull() ? null : threads); return c; diff --git a/editor/backend/src/main/java/com/engine/editor/service/SceneNodeService.java b/editor/backend/src/main/java/com/engine/editor/service/SceneNodeService.java index 33f3b39..cd0e655 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/SceneNodeService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/SceneNodeService.java @@ -15,9 +15,11 @@ public class SceneNodeService { private final ProjectService projectService; + private final SpelValidationService spelValidationService; - public SceneNodeService(ProjectService projectService) { + public SceneNodeService(ProjectService projectService, SpelValidationService spelValidationService) { this.projectService = projectService; + this.spelValidationService = spelValidationService; } // ── Response DTOs ───────────────────────────────────────────────────────── @@ -25,7 +27,7 @@ public SceneNodeService(ProjectService projectService) { public record VideoLayerData( long id, int layerOrder, String assetId, String assetFileName, boolean hasAlpha, Double duration, double startAt, Integer startAtFrames, - boolean alphaError, boolean freezeLastFrame + boolean alphaError, boolean freezeLastFrame, boolean loopLayer ) {} public record AudioTrackData( @@ -34,7 +36,7 @@ public record AudioTrackData( ) {} public record DecisionItemData( - long id, String decisionKey, boolean isDefault, int decisionOrder + long id, String decisionKey, boolean isDefault, int decisionOrder, String keyboardKey, String conditionExpression ) {} public record SceneDataResponse( @@ -71,10 +73,11 @@ public SceneDataResponse saveVideoLayers(String nodeId, List if (r.assetId() == null) throw new ProjectException("assetId is required for each layer"); requireAssetExists(jdbc, r.assetId()); int freeze = Boolean.TRUE.equals(r.freezeLastFrame()) ? 1 : 0; + int loop = Boolean.TRUE.equals(r.loopLayer()) ? 1 : 0; jdbc.update(""" - INSERT INTO node_video_layers (node_id, asset_id, layer_order, start_at, start_at_frames, freeze_last_frame) - VALUES (?, ?, ?, ?, ?, ?) - """, nodeId, r.assetId(), i, r.startAt() != null ? r.startAt() : 0.0, r.startAtFrames(), freeze); + INSERT INTO node_video_layers (node_id, asset_id, layer_order, start_at, start_at_frames, freeze_last_frame, loop_layer) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, nodeId, r.assetId(), i, r.startAt() != null ? r.startAt() : 0.0, r.startAtFrames(), freeze, loop); } return getSceneData(nodeId); } @@ -109,21 +112,40 @@ public SceneDataResponse saveDecisions(String nodeId, List throw new ProjectException("Exactly one decision must be marked as default"); Set keys = new HashSet<>(); + Set keyboardKeys = new HashSet<>(); for (DecisionItemRequest r : reqs) { - if (r.decisionKey() == null || r.decisionKey().isBlank()) + String decisionKey = r.decisionKey() != null ? r.decisionKey().trim() : null; + if (decisionKey == null || decisionKey.isBlank()) throw new ProjectException("decisionKey must not be blank"); - if (!keys.add(r.decisionKey())) - throw new ProjectException("Duplicate decisionKey: " + r.decisionKey()); + if (!keys.add(decisionKey)) + throw new ProjectException("Duplicate decisionKey: " + decisionKey); + String keyboardKey = normalizeKeyboardKey(r.keyboardKey()); + String conditionExpression = normalizeConditionExpression(r.conditionExpression()); + if (keyboardKey != null) { + if ("escape".equalsIgnoreCase(keyboardKey)) + throw new ProjectException("keyboardKey Escape is reserved"); + String normalizedKeyboardKey = keyboardKey.toLowerCase(Locale.ROOT); + if (!keyboardKeys.add(normalizedKeyboardKey)) + throw new ProjectException("Duplicate keyboardKey: " + keyboardKey); + } + if (conditionExpression != null) { + SpelValidationService.ValidationResult validation = spelValidationService.validate(conditionExpression, "boolean"); + if (!validation.valid()) { + throw new ProjectException("Invalid condition for decision '" + decisionKey + "': " + validation.error()); + } + } } jdbc.update("DELETE FROM scene_decisions WHERE node_id = ?", nodeId); for (int i = 0; i < reqs.size(); i++) { DecisionItemRequest r = reqs.get(i); int order = r.decisionOrder() != null ? r.decisionOrder() : i; + String keyboardKey = normalizeKeyboardKey(r.keyboardKey()); + String conditionExpression = normalizeConditionExpression(r.conditionExpression()); jdbc.update(""" - INSERT INTO scene_decisions (node_id, decision_key, is_default, decision_order) - VALUES (?, ?, ?, ?) - """, nodeId, r.decisionKey().trim(), Boolean.TRUE.equals(r.isDefault()) ? 1 : 0, order); + INSERT INTO scene_decisions (node_id, decision_key, is_default, decision_order, keyboard_key, condition_expression) + VALUES (?, ?, ?, ?, ?, ?) + """, nodeId, r.decisionKey().trim(), Boolean.TRUE.equals(r.isDefault()) ? 1 : 0, order, keyboardKey, conditionExpression); } return getSceneData(nodeId); } @@ -133,7 +155,7 @@ INSERT INTO scene_decisions (node_id, decision_key, is_default, decision_order) private List loadVideoLayers(JdbcTemplate jdbc, String nodeId) { List rows = jdbc.query(""" SELECT nvl.id, nvl.layer_order, nvl.asset_id, nvl.start_at, nvl.start_at_frames, - nvl.freeze_last_frame, a.file_name, a.has_alpha, a.duration + nvl.freeze_last_frame, nvl.loop_layer, a.file_name, a.has_alpha, a.duration FROM node_video_layers nvl JOIN assets a ON a.id = nvl.asset_id WHERE nvl.node_id = ? @@ -146,7 +168,7 @@ private List loadVideoLayers(JdbcTemplate jdbc, String nodeId) { boolean alphaError = i > 0 && !vl.hasAlpha(); if (alphaError) { rows.set(i, new VideoLayerData(vl.id(), vl.layerOrder(), vl.assetId(), - vl.assetFileName(), vl.hasAlpha(), vl.duration(), vl.startAt(), vl.startAtFrames(), true, vl.freezeLastFrame())); + vl.assetFileName(), vl.hasAlpha(), vl.duration(), vl.startAt(), vl.startAtFrames(), true, vl.freezeLastFrame(), vl.loopLayer())); } } return rows; @@ -163,7 +185,8 @@ private VideoLayerData mapVideoLayer(ResultSet rs) throws SQLException { rs.getDouble("start_at"), rs.getObject("start_at_frames") != null ? rs.getInt("start_at_frames") : null, false, - rs.getInt("freeze_last_frame") == 1 + rs.getInt("freeze_last_frame") == 1, + rs.getInt("loop_layer") == 1 ); } @@ -188,24 +211,32 @@ private List loadAudioTracks(JdbcTemplate jdbc, String nodeId) { private List loadDecisions(JdbcTemplate jdbc, String nodeId) { return jdbc.query(""" - SELECT id, decision_key, is_default, decision_order + SELECT id, decision_key, is_default, decision_order, keyboard_key, condition_expression FROM scene_decisions WHERE node_id = ? ORDER BY decision_order """, (rs, rowNum) -> new DecisionItemData( rs.getLong("id"), rs.getString("decision_key"), rs.getInt("is_default") == 1, - rs.getInt("decision_order") + rs.getInt("decision_order"), + rs.getString("keyboard_key"), + rs.getString("condition_expression") ), nodeId); } private Double computeDuration(List layers, List tracks) { double max = -1; for (VideoLayerData vl : layers) { + if (vl.loopLayer()) continue; // looped layers fill the scene, not determine its duration if (vl.duration() != null) max = Math.max(max, vl.startAt() + vl.duration()); } for (AudioTrackData at : tracks) { if (at.duration() != null) max = Math.max(max, at.startAt() + at.duration()); } + if (max < 0) { + for (VideoLayerData vl : layers) { + if (vl.duration() != null) max = Math.max(max, vl.startAt() + vl.duration()); + } + } return max < 0 ? null : max; } @@ -223,4 +254,16 @@ private void requireAssetExists(JdbcTemplate jdbc, String assetId) { if (count == null || count == 0) throw new ProjectException("Asset not found: " + assetId); } + + private String normalizeKeyboardKey(String keyboardKey) { + if (keyboardKey == null) return null; + String trimmed = keyboardKey.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeConditionExpression(String conditionExpression) { + if (conditionExpression == null) return null; + String trimmed = conditionExpression.trim(); + return trimmed.isEmpty() ? null : trimmed; + } } diff --git a/editor/backend/src/main/java/com/engine/editor/service/ScenePreviewService.java b/editor/backend/src/main/java/com/engine/editor/service/ScenePreviewService.java index 8d70175..7a078cb 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ScenePreviewService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ScenePreviewService.java @@ -78,7 +78,7 @@ private void runScenePreview(PreviewJob job) { job.setProgress(5, "Loading layers…"); // Load video layers List> layerRows = jdbc.queryForList(""" - SELECT nvl.layer_order, nvl.start_at, nvl.start_at_frames, nvl.freeze_last_frame, a.file_path, a.duration, a.has_alpha, a.codec + SELECT nvl.layer_order, nvl.start_at, nvl.start_at_frames, nvl.freeze_last_frame, nvl.loop_layer, a.file_path, a.duration, a.has_alpha, a.codec FROM node_video_layers nvl JOIN assets a ON a.id = nvl.asset_id WHERE nvl.node_id = ? ORDER BY nvl.layer_order @@ -110,9 +110,10 @@ private void runScenePreview(PreviewJob job) { double startAt = resolveStartAt(row.get("start_at"), row.get("start_at_frames"), fps); boolean hasAlpha = row.get("has_alpha") instanceof Number n ? n.intValue() == 1 : Boolean.TRUE.equals(row.get("has_alpha")); boolean freeze = row.get("freeze_last_frame") instanceof Number n ? n.intValue() == 1 : Boolean.TRUE.equals(row.get("freeze_last_frame")); + boolean loopLayer = row.get("loop_layer") instanceof Number n ? n.intValue() == 1 : Boolean.TRUE.equals(row.get("loop_layer")); String codec = row.get("codec") instanceof String s ? s : null; videoLayers.add(new VideoLayerSpec( - Path.of((String) row.get("file_path")), startAt, i, hasAlpha, freeze, codec)); + Path.of((String) row.get("file_path")), startAt, i, hasAlpha, freeze, codec, loopLayer)); } List audioTracks = new ArrayList<>(); @@ -153,8 +154,10 @@ private void runScenePreview(PreviewJob job) { } private double computeDuration(List> layers, List> audio, int fps) { - double max = 0; + double max = -1; for (Map row : layers) { + boolean loopLayer = row.get("loop_layer") instanceof Number n ? n.intValue() == 1 : Boolean.TRUE.equals(row.get("loop_layer")); + if (loopLayer) continue; Object dur = row.get("duration"); if (dur != null) { max = Math.max(max, toDouble(dur) + resolveStartAt(row.get("start_at"), row.get("start_at_frames"), fps)); @@ -166,7 +169,15 @@ private double computeDuration(List> layers, List row : layers) { + Object dur = row.get("duration"); + if (dur != null) { + max = Math.max(max, toDouble(dur) + resolveStartAt(row.get("start_at"), row.get("start_at_frames"), fps)); + } + } + } + return Math.max(max, 0); } private double resolveStartAt(Object startAtSeconds, Object startAtFrames, int fps) { diff --git a/editor/backend/src/main/java/com/engine/editor/service/ValidationService.java b/editor/backend/src/main/java/com/engine/editor/service/ValidationService.java index 4b626c6..6428047 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ValidationService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ValidationService.java @@ -73,6 +73,27 @@ public ValidationReport validate() { } } + // ── Error: explicit scene decision without matching outgoing edge ─────── + List> sceneDecisions = jdbc.queryForList(""" + SELECT sd.node_id, sd.decision_key, n.name AS node_name + FROM scene_decisions sd + JOIN nodes n ON n.id = sd.node_id + ORDER BY sd.node_id, sd.decision_order + """); + for (Map row : sceneDecisions) { + String sceneId = (String) row.get("node_id"); + String decisionKey = (String) row.get("decision_key"); + Integer edgeCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM edges WHERE source_node_id=? AND source_decision_key=?", + Integer.class, sceneId, decisionKey); + if (edgeCount == null || edgeCount == 0) { + errors.add(new ValidationIssue("error", "DECISION_MISSING_EDGE", + "Scene '" + row.get("node_name") + "' has decision '" + decisionKey + + "' but no outgoing edge is connected to it.", + sceneId, null)); + } + } + // ── Error: state node with != 1 outgoing edge ────────────────────────── for (Map n : nodes) { if (!"state".equals(n.get("type"))) continue; diff --git a/editor/backend/src/main/resources/bundled/runtime.jar b/editor/backend/src/main/resources/bundled/runtime.jar index 58555d1..d2d66b0 100644 Binary files a/editor/backend/src/main/resources/bundled/runtime.jar and b/editor/backend/src/main/resources/bundled/runtime.jar differ diff --git a/editor/backend/src/main/resources/db/migration/V10__add_loop_layer.sql b/editor/backend/src/main/resources/db/migration/V10__add_loop_layer.sql new file mode 100644 index 0000000..d1c2e10 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V10__add_loop_layer.sql @@ -0,0 +1 @@ +ALTER TABLE node_video_layers ADD COLUMN loop_layer INTEGER NOT NULL DEFAULT 0; diff --git a/editor/backend/src/main/resources/db/migration/V11__add_decision_hotkeys_and_hidden_buttons.sql b/editor/backend/src/main/resources/db/migration/V11__add_decision_hotkeys_and_hidden_buttons.sql new file mode 100644 index 0000000..dc7f7c6 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V11__add_decision_hotkeys_and_hidden_buttons.sql @@ -0,0 +1,2 @@ +ALTER TABLE scene_decisions ADD COLUMN keyboard_key TEXT; +ALTER TABLE project_config ADD COLUMN hide_decision_buttons INTEGER NOT NULL DEFAULT 0; diff --git a/editor/backend/src/main/resources/db/migration/V12__add_decision_input_indicator.sql b/editor/backend/src/main/resources/db/migration/V12__add_decision_input_indicator.sql new file mode 100644 index 0000000..e54227c --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V12__add_decision_input_indicator.sql @@ -0,0 +1 @@ +ALTER TABLE project_config ADD COLUMN show_decision_input_indicator INTEGER NOT NULL DEFAULT 0; diff --git a/editor/backend/src/main/resources/db/migration/V13__add_scene_decision_conditions.sql b/editor/backend/src/main/resources/db/migration/V13__add_scene_decision_conditions.sql new file mode 100644 index 0000000..b1385e4 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V13__add_scene_decision_conditions.sql @@ -0,0 +1 @@ +ALTER TABLE scene_decisions ADD COLUMN condition_expression TEXT; diff --git a/editor/backend/src/test/java/com/engine/editor/ffmpeg/FFmpegCommandBuilderTest.java b/editor/backend/src/test/java/com/engine/editor/ffmpeg/FFmpegCommandBuilderTest.java index 904ffae..45a63d7 100644 --- a/editor/backend/src/test/java/com/engine/editor/ffmpeg/FFmpegCommandBuilderTest.java +++ b/editor/backend/src/test/java/com/engine/editor/ffmpeg/FFmpegCommandBuilderTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; +import java.nio.file.Path; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -152,4 +154,30 @@ void resultIsImmutable() { assertThrows(UnsupportedOperationException.class, () -> cmd.add("extra")); } + + @Test + void compositeCommandScalesAndCentersLayersForSmallerOutputResolution() throws Exception { + FFmpegVideoProcessor processor = new FFmpegVideoProcessor(new FFprobeMediaAnalyzer()); + CompositeSpec spec = CompositeSpec.builder() + .videoLayers(List.of(new VideoLayerSpec(Path.of("/tmp/source.mp4"), 0, 0, false, false))) + .backgroundColor("0x000000") + .outputResolution("1280x720") + .fps(30) + .duration(5.0) + .outputPath(Path.of("/tmp/out.mp4")) + .build(); + + Method buildCompositeCommand = FFmpegVideoProcessor.class.getDeclaredMethod("buildCompositeCommand", CompositeSpec.class); + buildCompositeCommand.setAccessible(true); + + @SuppressWarnings("unchecked") + List cmd = (List) buildCompositeCommand.invoke(processor, spec); + + int filterIdx = cmd.indexOf("-filter_complex"); + assertNotEquals(-1, filterIdx); + + String filter = cmd.get(filterIdx + 1); + assertTrue(filter.contains("scale=w=1280:h=720:force_original_aspect_ratio=decrease,setsar=1[vs0]")); + assertTrue(filter.contains("[0:v][vs0]overlay=(W-w)/2:(H-h)/2:format=auto:eof_action=pass[vout]")); + } } diff --git a/editor/frontend/src/api/nodeEditor.ts b/editor/frontend/src/api/nodeEditor.ts index 84ee369..63444b4 100644 --- a/editor/frontend/src/api/nodeEditor.ts +++ b/editor/frontend/src/api/nodeEditor.ts @@ -5,9 +5,9 @@ import type { AssignmentData, ConditionData, SpelValidateResponse, } from '@/types' -export interface VideoLayerRequest { assetId: string; startAt: number; startAtFrames?: number | null; freezeLastFrame?: boolean } +export interface VideoLayerRequest { assetId: string; startAt: number; startAtFrames?: number | null; freezeLastFrame?: boolean; loopLayer?: boolean } export interface AudioTrackRequest { assetId: string; startAt: number; startAtFrames?: number | null } -export interface DecisionItemRequest { decisionKey: string; isDefault: boolean; decisionOrder: number } +export interface DecisionItemRequest { decisionKey: string; isDefault: boolean; decisionOrder: number; keyboardKey?: string | null; conditionExpression?: string | null } export interface AssignmentRequest { expression: string } export interface ConditionRequest { name: string | null; expression: string | null; isElse: boolean } diff --git a/editor/frontend/src/api/project.ts b/editor/frontend/src/api/project.ts index f6b06a0..21f957c 100644 --- a/editor/frontend/src/api/project.ts +++ b/editor/frontend/src/api/project.ts @@ -19,6 +19,7 @@ export interface CreateProjectPayload { audioBitRate?: number decisionTimeoutSecs?: number defaultBackgroundColor?: string + hideDecisionButtons?: boolean ffmpegThreads?: number | null } @@ -34,6 +35,8 @@ export interface UpdateProjectConfigPayload { decisionTimeoutSecs?: number defaultLocaleCode?: string defaultBackgroundColor?: string + hideDecisionButtons?: boolean + showDecisionInputIndicator?: boolean ffmpegThreadsAuto?: boolean ffmpegThreads?: number | null } diff --git a/editor/frontend/src/components/editor/ProjectSettingsPanel.tsx b/editor/frontend/src/components/editor/ProjectSettingsPanel.tsx index 596557b..482d897 100644 --- a/editor/frontend/src/components/editor/ProjectSettingsPanel.tsx +++ b/editor/frontend/src/components/editor/ProjectSettingsPanel.tsx @@ -15,6 +15,8 @@ interface ProjectSettingsForm { decisionTimeoutSecs: string defaultLocaleCode: string defaultBackgroundColor: string + hideDecisionButtons: boolean + showDecisionInputIndicator: boolean ffmpegThreads: string } @@ -31,6 +33,8 @@ function toForm(config: ProjectConfig): ProjectSettingsForm { decisionTimeoutSecs: String(config.decisionTimeoutSecs ?? 5), defaultLocaleCode: config.defaultLocaleCode ?? '', defaultBackgroundColor: config.defaultBackgroundColor ?? '#000000', + hideDecisionButtons: config.hideDecisionButtons ?? false, + showDecisionInputIndicator: config.showDecisionInputIndicator ?? false, ffmpegThreads: config.ffmpegThreads == null ? '' : String(config.ffmpegThreads), } } @@ -107,6 +111,8 @@ export default function ProjectSettingsPanel() { decisionTimeoutSecs: Number(currentForm.decisionTimeoutSecs), defaultLocaleCode: currentForm.defaultLocaleCode.trim() || undefined, defaultBackgroundColor, + hideDecisionButtons: currentForm.hideDecisionButtons, + showDecisionInputIndicator: currentForm.hideDecisionButtons && currentForm.showDecisionInputIndicator, ffmpegThreadsAuto, ffmpegThreads: ffmpegThreadsAuto ? undefined : Number(ffmpegThreadsTrimmed), }) @@ -196,8 +202,8 @@ export default function ProjectSettingsPanel() { setForm((prev) => prev ? { ...prev, audioSampleRate: e.target.value } : prev)} className="input-base" @@ -228,6 +234,38 @@ export default function ProjectSettingsPanel() { /> + +
+ + + {currentForm.hideDecisionButtons && ( + + )} +
+
+ s.projectConfig?.defaultBackgroundColor ?? '#000000' @@ -62,6 +80,35 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: } } + function updateDecisionConditionDraft(decisionId: number, value: string) { + setDecisionConditionDrafts((prev) => ({ ...prev, [decisionId]: value })) + } + + async function setDecisionConditionExpression(decisionId: number, conditionExpression: string | null) { + if (!data) return + const decisions = data.decisions.map(d => ( + d.id === decisionId ? { ...toDecisionReq(d), conditionExpression } : toDecisionReq(d) + )) + const result = await withSave(() => saveDecisions(nodeId, decisions)) + if (result) setData(result) + } + + async function toggleDecisionConditional(decisionId: number, enabled: boolean) { + if (!enabled) { + setDecisionConditionDrafts((prev) => ({ ...prev, [decisionId]: '' })) + await setDecisionConditionExpression(decisionId, null) + return + } + const nextExpression = (decisionConditionDrafts[decisionId] ?? '').trim() || DEFAULT_DECISION_CONDITION_EXPRESSION + setDecisionConditionDrafts((prev) => ({ ...prev, [decisionId]: nextExpression })) + await setDecisionConditionExpression(decisionId, nextExpression) + } + + async function saveDecisionCondition(decisionId: number) { + const nextExpression = (decisionConditionDrafts[decisionId] ?? '').trim() + await setDecisionConditionExpression(decisionId, nextExpression || null) + } + useEffect(() => { setLoading(true) Promise.all([getSceneData(nodeId), listAssets({ mediaType: 'video' })]) @@ -80,14 +127,20 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: // ── Video layers ───────────────────────────────────────────────────────── function toLayerReq(vl: VideoLayerData): VideoLayerRequest { - return { assetId: vl.assetId, startAt: vl.startAt, startAtFrames: vl.startAtFrames, freezeLastFrame: vl.freezeLastFrame } + return { + assetId: vl.assetId, + startAt: vl.startAt, + startAtFrames: vl.startAtFrames, + freezeLastFrame: vl.freezeLastFrame, + loopLayer: vl.loopLayer, + } } async function addVideoLayer(asset: Asset) { if (!data) return const newLayers: VideoLayerRequest[] = [ ...data.videoLayers.map(toLayerReq), - { assetId: asset.id, startAt: 0, freezeLastFrame: false }, + { assetId: asset.id, startAt: 0, startAtFrames: null, freezeLastFrame: false, loopLayer: false }, ] const result = await withSave(() => saveVideoLayers(nodeId, newLayers)) if (result) setData(result) @@ -137,6 +190,15 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: if (result) setData(result) } + async function updateLayerLoop(layerId: number, loopLayer: boolean) { + if (!data) return + const newLayers = data.videoLayers.map(vl => + ({ ...toLayerReq(vl), ...(vl.id === layerId ? { loopLayer } : {}) }) + ) + const result = await withSave(() => saveVideoLayers(nodeId, newLayers)) + if (result) setData(result) + } + // ── Audio tracks ────────────────────────────────────────────────────────── const [audioAssets, setAudioAssets] = useState([]) @@ -198,13 +260,38 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: // ── Decisions ───────────────────────────────────────────────────────────── const [newDecisionKey, setNewDecisionKey] = useState('') + const [capturingDecisionId, setCapturingDecisionId] = useState(null) + const [decisionConditionDrafts, setDecisionConditionDrafts] = useState>({}) + + useEffect(() => { + if (!data) return + setDecisionConditionDrafts( + Object.fromEntries(data.decisions.map((d) => [d.id, d.conditionExpression ?? ''])) + ) + }, [data]) + + function toDecisionReq(d: SceneDataResponse['decisions'][number]): DecisionItemRequest { + return { + decisionKey: d.decisionKey, + isDefault: d.isDefault, + decisionOrder: d.decisionOrder, + keyboardKey: d.keyboardKey ?? null, + conditionExpression: d.conditionExpression ?? null, + } + } async function addDecision() { if (!data || !newDecisionKey.trim()) return const existing = data.decisions const newDecisions: DecisionItemRequest[] = [ - ...existing.map(d => ({ decisionKey: d.decisionKey, isDefault: d.isDefault, decisionOrder: d.decisionOrder })), - { decisionKey: newDecisionKey.trim(), isDefault: existing.length === 0, decisionOrder: existing.length }, + ...existing.map(toDecisionReq), + { + decisionKey: newDecisionKey.trim(), + isDefault: existing.length === 0, + decisionOrder: existing.length, + keyboardKey: null, + conditionExpression: null, + }, ] const result = await withSave(() => saveDecisions(nodeId, newDecisions)) if (result) { setData(result); setNewDecisionKey('') } @@ -213,19 +300,52 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: async function removeDecision(decId: number) { if (!data) return const filtered = data.decisions.filter(d => d.id !== decId) - let decisions = filtered.map((d, i) => ({ decisionKey: d.decisionKey, isDefault: d.isDefault, decisionOrder: i })) + let decisions = filtered.map((d, i) => ({ ...toDecisionReq(d), decisionOrder: i })) if (decisions.length > 0 && !decisions.some(d => d.isDefault)) decisions[0].isDefault = true const result = await withSave(() => saveDecisions(nodeId, decisions)) - if (result) setData(result) + if (result) { + setData(result) + if (capturingDecisionId === decId) setCapturingDecisionId(null) + } } async function setDefaultDecision(decisionKey: string) { if (!data) return - const decisions = data.decisions.map(d => ({ decisionKey: d.decisionKey, isDefault: d.decisionKey === decisionKey, decisionOrder: d.decisionOrder })) + const decisions = data.decisions.map(d => ({ ...toDecisionReq(d), isDefault: d.decisionKey === decisionKey })) const result = await withSave(() => saveDecisions(nodeId, decisions)) if (result) setData(result) } + async function setDecisionKeyboardKey(decisionId: number, keyboardKey: string | null) { + if (!data) return + const decisions = data.decisions.map(d => ( + d.id === decisionId ? { ...toDecisionReq(d), keyboardKey } : toDecisionReq(d) + )) + const result = await withSave(() => saveDecisions(nodeId, decisions)) + if (result) { + setData(result) + setCapturingDecisionId(null) + } + } + + async function handleDecisionKeyCapture(decisionId: number, event: KeyboardEvent) { + if (event.repeat) return + if (event.key === 'Tab') return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + setCapturingDecisionId(null) + return + } + if (event.key === 'Backspace' || event.key === 'Delete') { + await setDecisionKeyboardKey(decisionId, null) + return + } + const keyboardKey = normalizeCapturedKeyboardKey(event.key) + if (!keyboardKey) return + await setDecisionKeyboardKey(decisionId, keyboardKey) + } + // ── Properties ──────────────────────────────────────────────────────────── async function saveProperties() { @@ -307,6 +427,7 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: onStartAtChange={v => updateLayerStartAt(vl.id, v)} onStartAtFramesChange={v => updateLayerStartAtFrames(vl.id, v)} onFreezeChange={v => updateLayerFreeze(vl.id, v)} + onLoopChange={v => updateLayerLoop(vl.id, v)} /> ))} {!data?.videoLayers.length && ( @@ -340,15 +461,60 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: {section === 'decisions' && ( <> {data?.decisions.map(d => ( -
+
+ {d.keyboardKey && ( + + )} +
+ + {!!d.conditionExpression?.trim() && ( + updateDecisionConditionDraft(d.id, value)} + onBlur={() => saveDecisionCondition(d.id)} + mode="boolean" + placeholder="#state['SCORE'] > 50" + /> + )} +
{d.isDefault && default} @@ -356,6 +522,9 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: {!data?.decisions.length && (

No decisions defined. Scene uses default CONTINUE.

)} + {!!data?.decisions.length && ( +

Click a key field, then press the keyboard key to assign. Press Delete or Backspace to clear. Conditional expressions can use `#KEY` and `#state['KEY']`.

+ )}
void; onMoveUp: () => void; onMoveDown: () => void onStartAtChange: (v: number) => void onStartAtFramesChange: (v: number | null) => void onFreezeChange: (v: boolean) => void + onLoopChange: (v: boolean) => void }) { const [localMs, setLocalMs] = useState(String(Math.round(layer.startAt * 1000))) const [localFrames, setLocalFrames] = useState(layer.startAtFrames != null ? String(layer.startAtFrames) : '') @@ -535,6 +705,15 @@ function VideoLayerRow({ layer, index, total, onRemove, onMoveUp, onMoveDown, on /> Hold last frame +
) } diff --git a/editor/frontend/src/components/editor/TransitionEditor.tsx b/editor/frontend/src/components/editor/TransitionEditor.tsx index c24d081..d345b64 100644 --- a/editor/frontend/src/components/editor/TransitionEditor.tsx +++ b/editor/frontend/src/components/editor/TransitionEditor.tsx @@ -91,7 +91,7 @@ export default function TransitionEditor({ edgeId }: TransitionEditorProps) { // Video layers function toLayerReq(l: TransitionLayerData): VideoLayerRequest { - return { assetId: l.assetId, startAt: l.startAt, startAtFrames: l.startAtFrames, freezeLastFrame: l.freezeLastFrame } + return { assetId: l.assetId, startAt: l.startAt, startAtFrames: l.startAtFrames, freezeLastFrame: l.freezeLastFrame, loopLayer: false } } async function updateLayerStartAt(layerId: number, startAt: number) { @@ -116,7 +116,7 @@ export default function TransitionEditor({ edgeId }: TransitionEditorProps) { if (!data) return const layers: VideoLayerRequest[] = [ ...data.videoLayers.map(toLayerReq), - { assetId: asset.id, startAt: 0, startAtFrames: null, freezeLastFrame: false }, + { assetId: asset.id, startAt: 0, startAtFrames: null, freezeLastFrame: false, loopLayer: false }, ] const result = await withSave(() => saveTransitionLayers(edgeId, layers)) if (result) setData(result) diff --git a/editor/frontend/src/types/index.ts b/editor/frontend/src/types/index.ts index f963f26..38b40f9 100644 --- a/editor/frontend/src/types/index.ts +++ b/editor/frontend/src/types/index.ts @@ -98,6 +98,7 @@ export interface VideoLayerData { startAtFrames: number | null alphaError: boolean freezeLastFrame: boolean + loopLayer: boolean } export interface AudioTrackData { @@ -115,6 +116,8 @@ export interface DecisionItemData { decisionKey: string isDefault: boolean decisionOrder: number + keyboardKey?: string | null + conditionExpression?: string | null } export interface SceneDataResponse { @@ -226,6 +229,8 @@ export interface ProjectConfig { decisionTimeoutSecs: number defaultLocaleCode?: string defaultBackgroundColor?: string + hideDecisionButtons?: boolean + showDecisionInputIndicator?: boolean ffmpegThreads?: number | null // null = Auto (let FFmpeg decide) } diff --git a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java index 588b604..d26b710 100644 --- a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java +++ b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java @@ -264,7 +264,8 @@ private void handleStatic(HttpExchange ex) throws IOException { private Map buildStateResponse(GameState s, String locale) { Manifest.NodeData scene = engine.nodeById(s.currentSceneId); - List decisions = engine.availableDecisions(s.currentSceneId); + List decisions = engine.availableDecisions(s, s.currentSceneId); + boolean hasExplicitDecisions = engine.sceneHasExplicitDecisions(s.currentSceneId); Map resp = new LinkedHashMap<>(); resp.put("currentSceneId", s.currentSceneId); @@ -274,10 +275,14 @@ private Map buildStateResponse(GameState s, String locale) { resp.put("duration", scene != null ? scene.computedDuration : null); resp.put("decisionAppearanceConfig", scene != null ? scene.decisionAppearanceConfig : null); resp.put("decisionTimeoutSecs", engine.decisionTimeoutSecs()); + resp.put("hideDecisionButtons", engine.hideDecisionButtons()); + resp.put("showDecisionInputIndicator", engine.showDecisionInputIndicator()); + resp.put("hasExplicitDecisions", hasExplicitDecisions); resp.put("decisions", decisions.stream().map(d -> { Map dm = new LinkedHashMap<>(); dm.put("key", d.key()); dm.put("isDefault", d.isDefault()); + dm.put("keyboardKey", d.keyboardKey()); return dm; }).toList()); resp.put("preloadUrls", engine.preloadUrlsForScene(s.currentSceneId)); diff --git a/runtime/src/main/java/com/engine/runtime/game/GameEngine.java b/runtime/src/main/java/com/engine/runtime/game/GameEngine.java index 5bc31c6..80fed61 100644 --- a/runtime/src/main/java/com/engine/runtime/game/GameEngine.java +++ b/runtime/src/main/java/com/engine/runtime/game/GameEngine.java @@ -18,6 +18,7 @@ public class GameEngine { private static final int MAX_TRAVERSAL_STEPS = 100; private static final Pattern ASSIGNMENT_RE = Pattern.compile("^\\s*#(\\w+)\\s*=(.+)$", Pattern.DOTALL); + private static final Pattern STATE_MAP_ACCESS_RE = Pattern.compile("#state\\[(?:'([^']+)'|\"([^\"]+)\")\\]"); private final Manifest manifest; private final Map nodeById = new HashMap<>(); @@ -48,6 +49,14 @@ public double decisionTimeoutSecs() { return manifest.project != null ? manifest.project.decisionTimeoutSecs : 5.0; } + public boolean hideDecisionButtons() { + return manifest.project != null && manifest.project.hideDecisionButtons; + } + + public boolean showDecisionInputIndicator() { + return manifest.project != null && manifest.project.showDecisionInputIndicator; + } + /** * Result of a decision traversal. * @@ -70,6 +79,10 @@ public TraversalResult decide(GameState state, String decisionKey) { Manifest.NodeData currentScene = nodeById(state.currentSceneId); if (currentScene == null) throw new IllegalArgumentException("Unknown current scene: " + state.currentSceneId); + if (!isDecisionAvailable(state, state.currentSceneId, decisionKey)) { + throw new IllegalArgumentException( + "Decision '" + decisionKey + "' is not currently available from scene " + state.currentSceneId); + } // Find the outgoing edge matching the decisionKey from the current scene Manifest.EdgeData startEdge = findDecisionEdge(state.currentSceneId, decisionKey); @@ -118,26 +131,32 @@ public TraversalResult decide(GameState state, String decisionKey) { * Returns the decisions available from the given scene, with their keys. * If the scene has no explicit decisions, returns a synthetic CONTINUE. */ - public List availableDecisions(String sceneId) { + public List availableDecisions(GameState state, String sceneId) { Manifest.NodeData scene = nodeById(sceneId); if (scene == null || scene.decisions == null || scene.decisions.isEmpty()) { - return List.of(new DecisionInfo("CONTINUE", true)); + return List.of(new DecisionInfo("CONTINUE", true, null)); } return scene.decisions.stream() .sorted(Comparator.comparingInt(d -> d.decisionOrder)) - .map(d -> new DecisionInfo(d.decisionKey, d.isDefault)) + .filter(d -> isDecisionAvailable(d, state)) + .map(d -> new DecisionInfo(d.decisionKey, d.isDefault, d.keyboardKey)) .toList(); } + public boolean sceneHasExplicitDecisions(String sceneId) { + Manifest.NodeData scene = nodeById(sceneId); + return scene != null && scene.decisions != null && !scene.decisions.isEmpty(); + } + /** Returns true when the scene has no explicit decisions AND has autoContinue set. */ public boolean sceneAutoContinues(String sceneId) { Manifest.NodeData scene = nodeById(sceneId); if (scene == null) return false; - boolean hasExplicitDecisions = scene.decisions != null && !scene.decisions.isEmpty(); + boolean hasExplicitDecisions = sceneHasExplicitDecisions(sceneId); return !hasExplicitDecisions && scene.autoContinue; } - public record DecisionInfo(String key, boolean isDefault) {} + public record DecisionInfo(String key, boolean isDefault, String keyboardKey) {} /** * Read-only traversal: returns the TraversalResult for a given decision without @@ -292,6 +311,7 @@ private Manifest.EdgeData findDecisionEdge(String sceneId, String decisionKey) { private EvaluationContext buildContext(GameState state) { SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadWriteDataBinding().build(); state.variables.forEach(ctx::setVariable); + ctx.setVariable("state", stateView(state)); return ctx; } @@ -299,7 +319,52 @@ private EvaluationContext buildContext(GameState state) { private void seedMissingVars(String expression, GameState state) { java.util.regex.Matcher m = java.util.regex.Pattern.compile("#(\\w+)").matcher(expression); while (m.find()) { - state.variables.putIfAbsent(m.group(1), 0); + String varName = m.group(1); + if ("state".equals(varName)) continue; + state.variables.putIfAbsent(varName, 0); + } + Matcher stateAccess = STATE_MAP_ACCESS_RE.matcher(expression); + while (stateAccess.find()) { + String key = stateAccess.group(1) != null ? stateAccess.group(1) : stateAccess.group(2); + if (key != null && !key.isBlank()) { + state.variables.putIfAbsent(key, 0); + } + } + } + + private boolean isDecisionAvailable(GameState state, String sceneId, String decisionKey) { + Manifest.NodeData scene = nodeById(sceneId); + if (scene == null || scene.decisions == null || scene.decisions.isEmpty()) { + return "CONTINUE".equals(decisionKey); + } + return scene.decisions.stream() + .filter(d -> decisionKey.equals(d.decisionKey)) + .findFirst() + .map(d -> isDecisionAvailable(d, state)) + .orElse(false); + } + + private boolean isDecisionAvailable(Manifest.DecisionEntry decision, GameState gameState) { + if (decision == null || decision.conditionExpression == null || decision.conditionExpression.isBlank()) { + return true; } + seedMissingVars(decision.conditionExpression, gameState); + EvaluationContext ctx = buildContext(gameState); + try { + Boolean result = parser.parseExpression(decision.conditionExpression).getValue(ctx, Boolean.class); + return Boolean.TRUE.equals(result); + } catch (Exception e) { + System.err.println("[GameEngine] Decision condition error '" + decision.conditionExpression + "': " + e.getMessage()); + return false; + } + } + + private Map stateView(GameState state) { + return new HashMap<>(state.variables) { + @Override + public Object get(Object key) { + return containsKey(key) ? super.get(key) : 0; + } + }; } } diff --git a/runtime/src/main/java/com/engine/runtime/game/Manifest.java b/runtime/src/main/java/com/engine/runtime/game/Manifest.java index 1b98b84..3cebf03 100644 --- a/runtime/src/main/java/com/engine/runtime/game/Manifest.java +++ b/runtime/src/main/java/com/engine/runtime/game/Manifest.java @@ -23,6 +23,8 @@ public static class ProjectConfig { @JsonProperty("fps") public int fps; @JsonProperty("decisionTimeoutSecs") public double decisionTimeoutSecs = 5.0; @JsonProperty("defaultLocaleCode") public String defaultLocaleCode; + @JsonProperty("hideDecisionButtons") public boolean hideDecisionButtons; + @JsonProperty("showDecisionInputIndicator") public boolean showDecisionInputIndicator; } // ── Node ────────────────────────────────────────────────────────────────── @@ -50,6 +52,8 @@ public static class DecisionEntry { @JsonProperty("decisionKey") public String decisionKey; @JsonProperty("isDefault") public boolean isDefault; @JsonProperty("decisionOrder") public int decisionOrder; + @JsonProperty("keyboardKey") public String keyboardKey; + @JsonProperty("conditionExpression") public String conditionExpression; } @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/runtime/src/main/resources/client/default.css b/runtime/src/main/resources/client/default.css index 9fec186..79eb188 100644 --- a/runtime/src/main/resources/client/default.css +++ b/runtime/src/main/resources/client/default.css @@ -348,6 +348,32 @@ body { .decision-btn:active { transform: scale(0.97); } .decision-btn.default { border-color: rgba(107, 138, 255, 0.6); } +#decision-input-indicator { + position: absolute; + left: 50%; + bottom: 24px; + z-index: 11; + max-width: calc(100% - 32px); + padding: 8px 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.22); + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.92); + font-size: 13px; + font-weight: 600; + text-align: center; + pointer-events: none; + opacity: 0; + transform: translateX(-50%) translateY(8px); + transition: opacity 0.25s, transform 0.25s; +} + +#decision-input-indicator.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + /* ── Countdown Timer ─────────────────────────────────────────────────────── */ #countdown { diff --git a/runtime/src/main/resources/client/game.js b/runtime/src/main/resources/client/game.js index cbf12a6..eabfa66 100644 --- a/runtime/src/main/resources/client/game.js +++ b/runtime/src/main/resources/client/game.js @@ -18,6 +18,8 @@ let preloadedSceneHls = {}; // url → Hls (preloaded next-scene) let currentMusicUrl = null; // currently playing music URL let gamePaused = false; let loopHandler = null; // persistent 'ended' handler for manual video looping +let sceneVideoListeners = []; // per-scene listeners attached to videoEl +let activeDecisionHotkeys = new Map(); // ── App state machine ──────────────────────────────────────────────────────── // Screens: 'menu' | 'game' | 'paused' | 'settings' @@ -38,6 +40,7 @@ const transEl = $('transition-el'); const freezeCanvas = $('freeze-canvas'); const decisionOverlay = $('decision-overlay'); const decisionButtons = $('decision-buttons'); +const decisionInputIndicator = $('decision-input-indicator'); const countdownEl = $('countdown'); const countdownNum = $('countdown-num'); const countdownArc = $('countdown-arc'); @@ -95,6 +98,49 @@ function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {} } +function normalizeDecisionHotkey(key) { + if (!key || key === 'Unidentified') return null; + if (key === ' ' || key === 'Spacebar') return 'space'; + return String(key).toLowerCase(); +} + +function formatDecisionHotkey(key) { + if (!key) return ''; + if (key === ' ') return 'Space'; + return key; +} + +function setActiveDecisionHotkeys(decisions) { + activeDecisionHotkeys = new Map(); + for (const d of decisions || []) { + const normalized = normalizeDecisionHotkey(d.keyboardKey); + if (normalized) activeDecisionHotkeys.set(normalized, d); + } +} + +function clearActiveDecisionHotkeys() { + activeDecisionHotkeys = new Map(); +} + +function showDecisionInputIndicator(decisions) { + if (!decisionInputIndicator) return; + const shouldShow = !!(currentState && currentState.hideDecisionButtons && currentState.showDecisionInputIndicator); + const hotkeys = (decisions || []) + .map((decision) => formatDecisionHotkey(decision.keyboardKey)) + .filter(Boolean); + const text = shouldShow && hotkeys.length > 0 + ? `Input ready — press ${hotkeys.join(' / ')}` + : ''; + decisionInputIndicator.textContent = text; + decisionInputIndicator.classList.toggle('visible', text !== ''); +} + +function hideDecisionInputIndicator() { + if (!decisionInputIndicator) return; + decisionInputIndicator.textContent = ''; + decisionInputIndicator.classList.remove('visible'); +} + function applySettings() { // Music musicEl.volume = settings.musicEnabled ? settings.musicVolume : 0; @@ -284,7 +330,24 @@ document.addEventListener('keydown', (e) => { applySettings(); showScreen(settingsReturnTo); } + return; + } + + if (appScreen !== 'game' || gamePaused || decisionMade) { + return; } + + const decision = activeDecisionHotkeys.get(normalizeDecisionHotkey(e.key)); + if (!decision) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + decisionMade = true; + clearCountdown(); + hideDecisions(); + makeDecision(decision.key); }); function pauseGame() { @@ -425,14 +488,29 @@ async function checkContinue() { } } +function addSceneVideoListener(type, handler, options) { + videoEl.addEventListener(type, handler, options); + const capture = typeof options === 'boolean' ? options : !!(options && options.capture); + sceneVideoListeners.push({ type, handler, capture }); +} + +function clearSceneVideoListeners() { + for (const l of sceneVideoListeners) { + videoEl.removeEventListener(l.type, l.handler, l.capture); + } + sceneVideoListeners = []; +} + // ── Scene loading ───────────────────────────────────────────────────────────── async function loadScene(state) { currentState = state; decisionMade = false; + clearActiveDecisionHotkeys(); - // Clean up any persistent loop handler from the previous scene - if (loopHandler) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; } + // Clean up scene listeners from previous scene to avoid stale handlers + clearSceneVideoListeners(); + loopHandler = null; hideDecisions(); hideCountdown(); @@ -477,6 +555,7 @@ async function loadScene(state) { videoEl.loop = false; // always false; looping is handled manually so 'ended' always fires const decisions = state.decisions || []; + const hasExplicitDecisions = !!state.hasExplicitDecisions; const timeout = state.decisionTimeoutSecs || 5; const isEnd = state.isEnd; @@ -484,7 +563,7 @@ async function loadScene(state) { // (short videos can fire 'ended' before listeners are attached otherwise) if (state.autoContinue) { - videoEl.addEventListener('ended', async () => { + addSceneVideoListener('ended', async () => { captureFreeze(); if (!decisionMade) { decisionMade = true; @@ -505,7 +584,7 @@ async function loadScene(state) { if (decisions.length > 0) { if (appearAt !== null) { - videoEl.addEventListener('timeupdate', function onTimeUpdate() { + addSceneVideoListener('timeupdate', function onTimeUpdate() { if (videoEl.currentTime >= appearAt) { videoEl.removeEventListener('timeupdate', onTimeUpdate); if (!decisionMade) showDecisions(decisions, timeout); @@ -525,20 +604,51 @@ async function loadScene(state) { videoEl.currentTime = 0; videoEl.play().catch(() => {}); }; - videoEl.addEventListener('ended', loopHandler); + addSceneVideoListener('ended', loopHandler); } else { // Non-looping: freeze on last frame and show decisions - videoEl.addEventListener('ended', function onEnded() { + addSceneVideoListener('ended', function onEnded() { videoEl.removeEventListener('ended', onEnded); captureFreeze(); if (isEnd) { showEndScreen(); return; } if (!decisionMade) showDecisions(decisions, timeout); }, { once: true }); } + } else if (hasExplicitDecisions) { + if (appearAt !== null) { + addSceneVideoListener('timeupdate', function onTimeUpdate() { + if (videoEl.currentTime >= appearAt) { + videoEl.removeEventListener('timeupdate', onTimeUpdate); + showUnavailableDecisionsError(); + } + }); + } + + if (loopVideo) { + let unavailableShown = false; + loopHandler = function onLoop() { + if (decisionMade) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; return; } + if (isEnd) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; captureFreeze(); showEndScreen(); return; } + if (!unavailableShown && appearAt === null) { + unavailableShown = true; + showUnavailableDecisionsError(); + return; + } + if (hlsInstance) hlsInstance.startLoad(0); + videoEl.currentTime = 0; + videoEl.play().catch(() => {}); + }; + addSceneVideoListener('ended', loopHandler); + } else { + addSceneVideoListener('ended', function onEnded() { + videoEl.removeEventListener('ended', onEnded); + if (!decisionMade) showUnavailableDecisionsError(); + }, { once: true }); + } } else if (isEnd) { - videoEl.addEventListener('ended', () => { captureFreeze(); showEndScreen(); }, { once: true }); + addSceneVideoListener('ended', () => { captureFreeze(); showEndScreen(); }, { once: true }); } else { - videoEl.addEventListener('ended', async () => { + addSceneVideoListener('ended', async () => { captureFreeze(); await makeDecision('CONTINUE'); }, { once: true }); @@ -629,29 +739,50 @@ function preloadScene(url) { // ── Decisions ───────────────────────────────────────────────────────────────── +function showUnavailableDecisionsError() { + if (decisionMade) return; + decisionMade = true; + clearCountdown(); + hideDecisions(); + captureFreeze(); + videoEl.pause(); + stopSubtitleSync(); + showError('No decisions are currently available for this scene.'); +} + function showDecisions(decisions, timeoutSecs) { + if (!decisions || decisions.length === 0) { + showUnavailableDecisionsError(); + return; + } decisionButtons.innerHTML = ''; + setActiveDecisionHotkeys(decisions); const defaultDecision = decisions.find(d => d.isDefault) || decisions[0]; + const hideDecisionButtons = !!(currentState && currentState.hideDecisionButtons); // Decision translations from the current state response const dtMap = (currentState && currentState.decisionTranslations) || {}; - for (const d of decisions) { - const btn = document.createElement('button'); - btn.className = 'decision-btn' + (d.isDefault ? ' default' : ''); - btn.textContent = dtMap[d.key] || d.key; - btn.addEventListener('click', () => { - if (decisionMade) return; - decisionMade = true; - clearCountdown(); - hideDecisions(); - makeDecision(d.key); - }); - decisionButtons.appendChild(btn); + if (!hideDecisionButtons) { + for (const d of decisions) { + const btn = document.createElement('button'); + btn.className = 'decision-btn' + (d.isDefault ? ' default' : ''); + const label = dtMap[d.key] || d.key; + btn.textContent = d.keyboardKey ? `${label} [${formatDecisionHotkey(d.keyboardKey)}]` : label; + btn.addEventListener('click', () => { + if (decisionMade) return; + decisionMade = true; + clearCountdown(); + hideDecisions(); + makeDecision(d.key); + }); + decisionButtons.appendChild(btn); + } } - decisionOverlay.classList.add('visible'); + decisionOverlay.classList.toggle('visible', !hideDecisionButtons); + showDecisionInputIndicator(decisions); startCountdown(timeoutSecs, () => { if (!decisionMade) { decisionMade = true; @@ -662,7 +793,10 @@ function showDecisions(decisions, timeoutSecs) { } function hideDecisions() { + clearActiveDecisionHotkeys(); decisionOverlay.classList.remove('visible'); + decisionButtons.innerHTML = ''; + hideDecisionInputIndicator(); } // ── Countdown ───────────────────────────────────────────────────────────────── @@ -702,16 +836,20 @@ function drawArc(fraction) { // ── Freeze frame ────────────────────────────────────────────────────────────── -function captureFreeze() { +function captureFreezeFrom(videoElement) { try { - freezeCanvas.width = videoEl.videoWidth || 1280; - freezeCanvas.height = videoEl.videoHeight || 720; + freezeCanvas.width = videoElement.videoWidth || 1280; + freezeCanvas.height = videoElement.videoHeight || 720; const ctx = freezeCanvas.getContext('2d'); - ctx.drawImage(videoEl, 0, 0, freezeCanvas.width, freezeCanvas.height); + ctx.drawImage(videoElement, 0, 0, freezeCanvas.width, freezeCanvas.height); freezeCanvas.style.display = 'block'; } catch { /* cross-origin or no frame */ } } +function captureFreeze() { + captureFreezeFrom(videoEl); +} + function hideFreeze() { freezeCanvas.style.display = 'none'; } @@ -767,6 +905,8 @@ async function playTransition(trans) { function cleanup() { if (cleaned) return; cleaned = true; + captureFreezeFrom(transEl); + transEl.pause(); transEl.classList.remove('active'); transEl.style.backgroundColor = ''; if (transHls) { transHls.destroy(); transHls = null; } diff --git a/runtime/src/main/resources/client/index.html b/runtime/src/main/resources/client/index.html index 54cd25d..19f775b 100644 --- a/runtime/src/main/resources/client/index.html +++ b/runtime/src/main/resources/client/index.html @@ -57,6 +57,8 @@

Arvexis

+
+