diff --git a/editor/backend/pom.xml b/editor/backend/pom.xml index 4be68d9..1f26c97 100644 --- a/editor/backend/pom.xml +++ b/editor/backend/pom.xml @@ -13,7 +13,7 @@ com.engine editor-backend - 0.4.0-RELEASE + 0.5.0-RELEASE editor-backend Arvexis - Editor Backend diff --git a/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java b/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java index f796499..a5a8895 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java @@ -10,14 +10,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Set; /** - * Manage the project's custom.css file for runtime UI customization. + * Manage the project's custom CSS files for runtime UI customization. + * Supports three files: custom.css (general), buttons.css, subtitles.css. */ @RestController @RequestMapping("/api/custom-css") public class CustomCssController { + private static final Set ALLOWED_FILES = Set.of("custom", "buttons", "subtitles"); + private final ProjectService projectService; public CustomCssController(ProjectService projectService) { @@ -26,24 +30,45 @@ public CustomCssController(ProjectService projectService) { @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) public ResponseEntity getCustomCss() throws IOException { - Path cssFile = cssPath(); + return readCssFile("custom"); + } + + @PutMapping(consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity> saveCustomCss(@RequestBody String content) throws IOException { + return writeCssFile("custom", content); + } + + @GetMapping(value = "/{name}", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity getNamedCss(@PathVariable String name) throws IOException { + if (!ALLOWED_FILES.contains(name)) return ResponseEntity.notFound().build(); + return readCssFile(name); + } + + @PutMapping(value = "/{name}", consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity> saveNamedCss(@PathVariable String name, + @RequestBody String content) throws IOException { + if (!ALLOWED_FILES.contains(name)) return ResponseEntity.notFound().build(); + return writeCssFile(name, content); + } + + private ResponseEntity readCssFile(String name) throws IOException { + Path cssFile = cssPath(name); if (Files.exists(cssFile)) { return ResponseEntity.ok(Files.readString(cssFile, StandardCharsets.UTF_8)); } return ResponseEntity.ok(""); } - @PutMapping(consumes = MediaType.TEXT_PLAIN_VALUE) - public ResponseEntity> saveCustomCss(@RequestBody String content) throws IOException { - Path cssFile = cssPath(); + private ResponseEntity> writeCssFile(String name, String content) throws IOException { + Path cssFile = cssPath(name); Files.writeString(cssFile, content, StandardCharsets.UTF_8); return ResponseEntity.ok(Map.of( - "message", "custom.css saved", + "message", name + ".css saved", "path", cssFile.toAbsolutePath().toString() )); } - private Path cssPath() { - return projectService.getCurrentProjectPath().resolve("custom.css"); + private Path cssPath(String name) { + return projectService.getCurrentProjectPath().resolve(name + ".css"); } } diff --git a/editor/backend/src/main/java/com/engine/editor/controller/TransitionController.java b/editor/backend/src/main/java/com/engine/editor/controller/TransitionController.java index 61b5677..29747ce 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/TransitionController.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/TransitionController.java @@ -51,4 +51,12 @@ public ResponseEntity saveAudio( ) { return ResponseEntity.ok(transitionService.saveAudioTracks(id, tracks)); } + + @PutMapping("/background-color") + public ResponseEntity setBackgroundColor( + @PathVariable String id, + @RequestBody Map body + ) { + return ResponseEntity.ok(transitionService.setBackgroundColor(id, body.get("backgroundColor"))); + } } diff --git a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java index b438ddc..10479ad 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java @@ -4,6 +4,7 @@ public record UpdateNodeRequest( String name, Boolean isEnd, Boolean autoContinue, + Boolean loopVideo, String backgroundColor, String decisionAppearanceConfig, String musicAssetId, 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 9463210..cf358ae 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 @@ -140,6 +140,33 @@ public void generateHls(Path inputPath, Path outputDir, HlsOptions options) thro if (!result.isSuccess()) { throw new IOException("FFmpeg HLS generation failed: " + result.getStderr()); } + // FFmpeg writes TARGETDURATION as floor(max_segment_duration), which gives 0 for + // clips shorter than 1 second. Fix it to ceil(max_extinf) per HLS spec (RFC 8216 §4.3.3.1). + fixTargetDuration(outputDir.resolve(options.getPlaylistName())); + } + + private static void fixTargetDuration(Path playlist) { + if (!Files.exists(playlist)) return; + try { + String content = Files.readString(playlist); + // Find the maximum EXTINF duration value + double maxExtinf = 0; + for (String line : content.split("\n")) { + line = line.trim(); + if (line.startsWith("#EXTINF:")) { + try { + double d = Double.parseDouble(line.substring(8).replace(",", "").trim()); + if (d > maxExtinf) maxExtinf = d; + } catch (NumberFormatException ignored) {} + } + } + int correct = (int) Math.ceil(maxExtinf); + if (correct < 1) correct = 1; + String fixed = content.replaceFirst( + "#EXT-X-TARGETDURATION:\\d+", + "#EXT-X-TARGETDURATION:" + correct); + if (!fixed.equals(content)) Files.writeString(playlist, fixed); + } catch (IOException ignored) {} } @Override @@ -173,7 +200,7 @@ private List buildCompositeCommand(CompositeSpec spec) { List layers = spec.getVideoLayers(); for (VideoLayerSpec layer : layers) { List preOpts = new ArrayList<>(); - if (layer.isHasAlpha()) { + if (layer.isHasAlpha() && "vp9".equalsIgnoreCase(layer.getCodec())) { preOpts.add("-vcodec"); preOpts.add("libvpx-vp9"); } diff --git a/editor/backend/src/main/java/com/engine/editor/ffmpeg/VideoLayerSpec.java b/editor/backend/src/main/java/com/engine/editor/ffmpeg/VideoLayerSpec.java index c22a31b..9780efe 100644 --- a/editor/backend/src/main/java/com/engine/editor/ffmpeg/VideoLayerSpec.java +++ b/editor/backend/src/main/java/com/engine/editor/ffmpeg/VideoLayerSpec.java @@ -8,15 +8,21 @@ public class VideoLayerSpec { private final double startAt; private final int order; private final boolean hasAlpha; + private final String codec; private final boolean freezeLastFrame; public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha, boolean freezeLastFrame) { + this(filePath, startAt, order, hasAlpha, freezeLastFrame, null); + } + + public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha, boolean freezeLastFrame, String codec) { this.filePath = filePath; this.startAt = startAt; this.order = order; this.hasAlpha = hasAlpha; this.freezeLastFrame = freezeLastFrame; + this.codec = codec; } public Path getFilePath() { return filePath; } @@ -24,4 +30,5 @@ public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha public int getOrder() { return order; } public boolean isHasAlpha() { return hasAlpha; } public boolean isFreezeLastFrame() { return freezeLastFrame; } + public String getCodec() { return codec; } } diff --git a/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java b/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java index 5612df5..abea79a 100644 --- a/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java +++ b/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java @@ -14,6 +14,7 @@ public record NodeExit(String key, String label, boolean isDefault) {} private boolean isRoot; private boolean isEnd; private boolean autoContinue; + private boolean loopVideo; private String backgroundColor; private String decisionAppearanceConfig; private String musicAssetId; @@ -44,6 +45,10 @@ public GraphNode() {} public boolean isAutoContinue() { return autoContinue; } public void setAutoContinue(boolean autoContinue) { this.autoContinue = autoContinue; } + @JsonProperty("loopVideo") + public boolean isLoopVideo() { return loopVideo; } + public void setLoopVideo(boolean loopVideo) { this.loopVideo = loopVideo; } + public String getBackgroundColor() { return backgroundColor; } public void setBackgroundColor(String backgroundColor) { this.backgroundColor = backgroundColor; } diff --git a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java index 5680e19..4ff475d 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java @@ -271,10 +271,12 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, buildReadme(projectName), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - // custom.css (user-editable runtime styles) - Path customCss = projectDir.resolve("custom.css"); - if (Files.exists(customCss)) { - Files.copy(customCss, distDir.resolve("custom.css"), StandardCopyOption.REPLACE_EXISTING); + // User-editable runtime CSS files (buttons, subtitles, general) + for (String cssName : List.of("buttons.css", "subtitles.css", "custom.css")) { + Path cssFile = projectDir.resolve(cssName); + if (Files.exists(cssFile)) { + Files.copy(cssFile, distDir.resolve(cssName), StandardCopyOption.REPLACE_EXISTING); + } } // Create dist.zip @@ -318,7 +320,7 @@ private void createZip(Path distDir, Path outputBase, Path assetsDir, Path zipFi Files.newOutputStream(zipFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) { - // Add dist/ contents (manifest, runtime.jar, scripts, README, custom.css) + // Add dist/ contents (manifest, runtime.jar, scripts, README, CSS files) try (var walk = Files.walk(distDir)) { walk.filter(Files::isRegularFile).forEach(p -> { String name = "game/" + distDir.relativize(p).toString(); @@ -389,7 +391,8 @@ private void compileScene(Map node, ProjectConfigData config, 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")); - layers.add(new VideoLayerSpec(Path.of(filePath), toDouble(r.get("startAt")), i, hasAlpha, freeze)); + 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)); } List tracks = new ArrayList<>(); @@ -491,7 +494,8 @@ private void compileVideoTransition(Map trans, ProjectConfigData if (fp == 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")); - layers.add(new VideoLayerSpec(Path.of(fp), toDouble(r.get("startAt")), i, hasAlpha, freeze)); + String codec = r.get("codec") instanceof String s ? s : null; + layers.add(new VideoLayerSpec(Path.of(fp), toDouble(r.get("startAt")), i, hasAlpha, freeze, codec)); } List tracks = new ArrayList<>(); for (int i = 0; i < audioData.size(); i++) { @@ -503,7 +507,10 @@ private void compileVideoTransition(Map trans, ProjectConfigData double duration = toDouble(trans.get("duration")); if (duration <= 0) duration = 2.0; - String bgHex = config.getDefaultBackgroundColor() != null ? config.getDefaultBackgroundColor() : "#000000"; + // Prefer per-transition backgroundColor; fall back to project default, then white + String bgHex = trans.get("backgroundColor") instanceof String s && !s.isBlank() ? s + : config.getDefaultBackgroundColor() != null ? config.getDefaultBackgroundColor() + : "#ffffff"; String ffmpegBg = bgHex.replaceFirst("^#", "0x"); CompositeSpec spec = CompositeSpec.builder() diff --git a/editor/backend/src/main/java/com/engine/editor/service/GraphService.java b/editor/backend/src/main/java/com/engine/editor/service/GraphService.java index a47678a..7c9d68f 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/GraphService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/GraphService.java @@ -84,6 +84,7 @@ public GraphNode updateNode(String id, UpdateNodeRequest req) { String name = req.name() != null ? req.name().trim() : existing.getName(); boolean isEnd = req.isEnd() != null ? req.isEnd() : existing.isEnd(); boolean autoCont = req.autoContinue() != null ? req.autoContinue() : existing.isAutoContinue(); + boolean loopVid = req.loopVideo() != null ? req.loopVideo() : existing.isLoopVideo(); String bgColor = req.backgroundColor() != null ? req.backgroundColor() : existing.getBackgroundColor(); String dac = req.decisionAppearanceConfig() != null ? req.decisionAppearanceConfig() @@ -96,10 +97,10 @@ public GraphNode updateNode(String id, UpdateNodeRequest req) { if (name.isBlank()) throw new ProjectException("Node name must not be blank"); jdbc.update(""" - UPDATE nodes SET name=?, is_end=?, auto_continue=?, background_color=?, + UPDATE nodes SET name=?, is_end=?, auto_continue=?, loop_video=?, background_color=?, decision_appearance_config=?, music_asset_id=?, pos_x=?, pos_y=? WHERE id=? - """, name, isEnd ? 1 : 0, autoCont ? 1 : 0, bgColor, dac, musicAsset, posX, posY, id); + """, name, isEnd ? 1 : 0, autoCont ? 1 : 0, loopVid ? 1 : 0, bgColor, dac, musicAsset, posX, posY, id); return getNode(id); } @@ -228,6 +229,7 @@ private GraphNode mapNode(ResultSet rs, int row) throws SQLException { n.setRoot(rs.getInt("is_root") == 1); n.setEnd(rs.getInt("is_end") == 1); n.setAutoContinue(rs.getInt("auto_continue") == 1); + n.setLoopVideo(rs.getInt("loop_video") == 1); n.setBackgroundColor(rs.getString("background_color")); n.setDecisionAppearanceConfig(rs.getString("decision_appearance_config")); n.setMusicAssetId(rs.getString("music_asset_id")); 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 b6f65ba..9545648 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 @@ -59,8 +59,8 @@ public Path generateManifest() { List> edgeList = buildEdges(reachable, config, jdbc); manifest.put("edges", edgeList); - // Localization - manifest.put("localization", buildLocalization(jdbc)); + // Localization (filtered to reachable scenes) + manifest.put("localization", buildLocalization(reachable, jdbc)); // Write to file Path outFile = projectDir.resolve(MANIFEST_FILE); @@ -144,10 +144,11 @@ private void fillSceneNode(Map n, String nodeId, config.getDefaultBackgroundColor() != null ? config.getDefaultBackgroundColor() : "#000000", nodeId)); - // Decision appearance + // Decision appearance & loop flag Map nodeFullRow = jdbc.queryForMap("SELECT * FROM nodes WHERE id=?", nodeId); String cfg = (String) nodeFullRow.get("decision_appearance_config"); n.put("decisionAppearanceConfig", cfg); + n.put("loopVideo", intFlag(nodeFullRow.get("loop_video"))); // Background music asset String musicAssetId = (String) nodeFullRow.get("music_asset_id"); @@ -165,7 +166,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, - a.id AS asset_id, a.file_path, a.file_name, a.has_alpha, a.duration + 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 """, nodeId); @@ -178,6 +179,7 @@ private void fillSceneNode(Map n, String nodeId, l.put("assetFileName", r.get("file_name")); l.put("assetRelPath", relPath(config.getAssetsDirectory(), (String) r.get("file_path"))); 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("duration", r.get("duration")); l.put("startAtFrames", r.get("start_at_frames")); @@ -289,13 +291,14 @@ private List> buildEdges(Set reachable, private Map buildTransition(String edgeId, ProjectConfigData config, JdbcTemplate jdbc) { List> rows = jdbc.queryForList( - "SELECT type, duration FROM edge_transitions WHERE edge_id=?", edgeId); + "SELECT type, duration, background_color FROM edge_transitions WHERE edge_id=?", edgeId); if (rows.isEmpty()) return null; Map row = rows.get(0); Map t = new LinkedHashMap<>(); - t.put("type", row.get("type")); - t.put("duration", row.get("duration")); + t.put("type", row.get("type")); + t.put("duration", row.get("duration")); + t.put("backgroundColor", row.get("background_color")); if ("video".equals(row.get("type"))) { t.put("videoLayers", buildTransVideoLayers(edgeId, config, jdbc)); @@ -310,7 +313,7 @@ private List> buildTransVideoLayers(String edgeId, int fps = config.getFps() > 0 ? config.getFps() : 30; return jdbc.queryForList(""" SELECT tvl.layer_order, tvl.start_at, tvl.start_at_frames, tvl.freeze_last_frame, - a.id AS asset_id, a.file_path, a.file_name, a.has_alpha, a.duration + a.id AS asset_id, a.file_path, a.file_name, a.has_alpha, a.codec, a.duration FROM transition_video_layers tvl JOIN assets a ON a.id=tvl.asset_id WHERE tvl.edge_id=? ORDER BY tvl.layer_order """, edgeId).stream().map(r -> { @@ -320,6 +323,7 @@ private List> buildTransVideoLayers(String edgeId, l.put("assetFileName", r.get("file_name")); l.put("assetRelPath", relPath(config.getAssetsDirectory(), (String) r.get("file_path"))); 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("duration", r.get("duration")); l.put("startAtFrames", r.get("start_at_frames")); @@ -351,7 +355,7 @@ private List> buildTransAudioTracks(String edgeId, // ── Localization ────────────────────────────────────────────────────────── - private Map buildLocalization(JdbcTemplate jdbc) { + private Map buildLocalization(Set reachableSceneIds, JdbcTemplate jdbc) { Map loc = new LinkedHashMap<>(); loc.put("locales", jdbc.queryForList("SELECT code, name FROM locales ORDER BY code") @@ -359,7 +363,9 @@ private Map buildLocalization(JdbcTemplate jdbc) { loc.put("subtitles", jdbc.queryForList( "SELECT id, scene_id, locale_code, start_time, end_time, text FROM subtitle_entries ORDER BY scene_id, locale_code, start_time") - .stream().map(r -> { + .stream() + .filter(r -> reachableSceneIds.contains(r.get("scene_id"))) + .map(r -> { Map s = new LinkedHashMap<>(); s.put("id", r.get("id")); s.put("sceneId", r.get("scene_id")); @@ -372,7 +378,9 @@ private Map buildLocalization(JdbcTemplate jdbc) { loc.put("decisionTranslations", jdbc.queryForList( "SELECT id, decision_key, scene_id, locale_code, label FROM decision_translations ORDER BY scene_id, locale_code") - .stream().map(r -> { + .stream() + .filter(r -> reachableSceneIds.contains(r.get("scene_id"))) + .map(r -> { Map dt = new LinkedHashMap<>(); dt.put("id", r.get("id")); dt.put("decisionKey", r.get("decision_key")); 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 0b9fc4e..8d70175 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 + 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 FROM node_video_layers nvl JOIN assets a ON a.id = nvl.asset_id WHERE nvl.node_id = ? ORDER BY nvl.layer_order @@ -110,8 +110,9 @@ 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")); + 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)); + Path.of((String) row.get("file_path")), startAt, i, hasAlpha, freeze, codec)); } List audioTracks = new ArrayList<>(); diff --git a/editor/backend/src/main/java/com/engine/editor/service/TransitionPreviewService.java b/editor/backend/src/main/java/com/engine/editor/service/TransitionPreviewService.java index 36abd77..fc8f794 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/TransitionPreviewService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/TransitionPreviewService.java @@ -69,12 +69,13 @@ private void runTransitionPreview(PreviewJob job) { // Load transition info List> transRows = jdbc.queryForList( - "SELECT type, duration FROM edge_transitions WHERE edge_id=?", edgeId); + "SELECT type, duration, background_color FROM edge_transitions WHERE edge_id=?", edgeId); String transType = transRows.isEmpty() ? "cut" : (String) transRows.get(0).get("type"); Object durObj = transRows.isEmpty() ? null : transRows.get(0).get("duration"); double transDur = durObj instanceof Number n ? n.doubleValue() : 1.0; if (transDur <= 0) transDur = 1.0; + String transBg = transRows.isEmpty() ? null : (String) transRows.get(0).get("background_color"); String resolution = config.getPreviewResolution() != null ? config.getPreviewResolution() : "1280x720"; @@ -89,7 +90,7 @@ private void runTransitionPreview(PreviewJob job) { Integer threads = config.getFfmpegThreads(); if ("video".equals(transType)) { - compileVideoTransition(edgeId, jdbc, config, resolution, fps, threads, outFile, transDur, job); + compileVideoTransition(edgeId, jdbc, config, transBg, resolution, fps, threads, outFile, transDur, job); } else if ("cut".equals(transType) || !XFADE_MAP.containsKey(transType)) { compileCutTransition(resolution, fps, threads, outFile, job); } else { @@ -163,12 +164,12 @@ private void compileCutTransition(String resolution, int fps, Integer ffmpegThre // ── Video-based: composite layers same as scene preview ─────────────────── private void compileVideoTransition(String edgeId, JdbcTemplate jdbc, - ProjectConfigData config, + ProjectConfigData config, String transitionBg, String resolution, int fps, Integer ffmpegThreads, Path outFile, double fallbackDur, PreviewJob job) throws Exception { List> layerRows = jdbc.queryForList(""" - SELECT tvl.layer_order, tvl.start_at, tvl.start_at_frames, tvl.freeze_last_frame, a.file_path, a.duration, a.has_alpha + SELECT tvl.layer_order, tvl.start_at, tvl.start_at_frames, tvl.freeze_last_frame, a.file_path, a.duration, a.has_alpha, a.codec FROM transition_video_layers tvl JOIN assets a ON a.id = tvl.asset_id WHERE tvl.edge_id = ? ORDER BY tvl.layer_order @@ -186,8 +187,9 @@ private void compileVideoTransition(String edgeId, JdbcTemplate jdbc, Map row = layerRows.get(i); 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")); + String codec = row.get("codec") instanceof String s ? s : null; videoLayers.add(new VideoLayerSpec( - Path.of((String) row.get("file_path")), resolveStartAt(row.get("start_at"), row.get("start_at_frames"), fps), i, hasAlpha, freeze)); + Path.of((String) row.get("file_path")), resolveStartAt(row.get("start_at"), row.get("start_at_frames"), fps), i, hasAlpha, freeze, codec)); } List audioTracks = new ArrayList<>(); @@ -200,7 +202,9 @@ private void compileVideoTransition(String edgeId, JdbcTemplate jdbc, double duration = computeDuration(layerRows, audioRows, fps); if (duration <= 0) duration = fallbackDur; - String bgHex = config.getDefaultBackgroundColor() != null ? config.getDefaultBackgroundColor() : "#000000"; + String bgHex = transitionBg != null && !transitionBg.isBlank() ? transitionBg + : config.getDefaultBackgroundColor() != null ? config.getDefaultBackgroundColor() + : "#ffffff"; String ffmpegBg = bgHex.replaceFirst("^#", "0x"); CompositeSpec spec = CompositeSpec.builder() diff --git a/editor/backend/src/main/java/com/engine/editor/service/TransitionService.java b/editor/backend/src/main/java/com/engine/editor/service/TransitionService.java index f75dcd2..f5aeac5 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/TransitionService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/TransitionService.java @@ -47,6 +47,7 @@ public record TransitionResponse( boolean transitionAllowed, String type, Double duration, + String backgroundColor, List videoLayers, List audioTracks ) {} @@ -60,7 +61,7 @@ public TransitionResponse getTransition(String edgeId) { // Get edge + target node type in one query List row = jdbc.query(""" SELECT e.source_node_id, e.target_node_id, n.type AS target_type, - t.type AS trans_type, t.duration + t.type AS trans_type, t.duration, t.background_color FROM edges e JOIN nodes n ON n.id = e.target_node_id LEFT JOIN edge_transitions t ON t.edge_id = e.id @@ -70,7 +71,8 @@ public TransitionResponse getTransition(String edgeId) { rs.getString("target_node_id"), rs.getString("target_type"), rs.getString("trans_type"), - rs.getObject("duration") != null ? rs.getDouble("duration") : null + rs.getObject("duration") != null ? rs.getDouble("duration") : null, + rs.getString("background_color") }, edgeId); if (row.isEmpty()) throw new ProjectException("Edge not found: " + edgeId); @@ -81,13 +83,14 @@ public TransitionResponse getTransition(String edgeId) { String targetType = (String) r[2]; String transType = (String) r[3]; Double duration = (Double) r[4]; + String bgColor = (String) r[5]; boolean allowed = "scene".equals(targetType); List layers = allowed ? loadVideoLayers(jdbc, edgeId) : List.of(); List audio = allowed ? loadAudioTracks(jdbc, edgeId) : List.of(); return new TransitionResponse(edgeId, sourceNodeId, targetNodeId, targetType, - allowed, transType, duration, layers, audio); + allowed, transType, duration, bgColor, layers, audio); } // ── Update type/duration ────────────────────────────────────────────────── @@ -102,10 +105,16 @@ public TransitionResponse setTransitionType(String edgeId, String type, Double d if (duration != null && duration > MAX_SECS) throw new ProjectException("Transition duration cannot exceed " + MAX_SECS + " seconds"); + // Preserve existing backgroundColor when only changing type/duration + String existingBg = null; + try { + existingBg = jdbc.queryForObject( + "SELECT background_color FROM edge_transitions WHERE edge_id=?", String.class, edgeId); + } catch (Exception ignored) {} jdbc.update("DELETE FROM edge_transitions WHERE edge_id = ?", edgeId); if (!"none".equals(type)) { - jdbc.update("INSERT INTO edge_transitions (edge_id, type, duration) VALUES (?, ?, ?)", - edgeId, type, duration); + jdbc.update("INSERT INTO edge_transitions (edge_id, type, duration, background_color) VALUES (?, ?, ?, ?)", + edgeId, type, duration, existingBg); } if (!"video".equals(type)) { jdbc.update("DELETE FROM transition_video_layers WHERE edge_id = ?", edgeId); @@ -114,6 +123,17 @@ public TransitionResponse setTransitionType(String edgeId, String type, Double d return getTransition(edgeId); } + // ── Background color ────────────────────────────────────────────────────── + + public TransitionResponse setBackgroundColor(String edgeId, String backgroundColor) { + JdbcTemplate jdbc = projectService.requireJdbc(); + requireEdge(jdbc, edgeId); + requireTargetIsScene(jdbc, edgeId); + jdbc.update("UPDATE edge_transitions SET background_color = ? WHERE edge_id = ?", + backgroundColor, edgeId); + return getTransition(edgeId); + } + // ── Video layers ────────────────────────────────────────────────────────── public TransitionResponse saveVideoLayers(String edgeId, List reqs) { diff --git a/editor/backend/src/main/resources/bundled/runtime.jar b/editor/backend/src/main/resources/bundled/runtime.jar index 432af5a..58555d1 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/V8__add_loop_video.sql b/editor/backend/src/main/resources/db/migration/V8__add_loop_video.sql new file mode 100644 index 0000000..76e34f4 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V8__add_loop_video.sql @@ -0,0 +1 @@ +ALTER TABLE nodes ADD COLUMN loop_video INTEGER NOT NULL DEFAULT 0; diff --git a/editor/backend/src/main/resources/db/migration/V9__add_transition_background_color.sql b/editor/backend/src/main/resources/db/migration/V9__add_transition_background_color.sql new file mode 100644 index 0000000..9860d82 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V9__add_transition_background_color.sql @@ -0,0 +1 @@ +ALTER TABLE edge_transitions ADD COLUMN background_color TEXT; diff --git a/editor/frontend/src/api/customCss.ts b/editor/frontend/src/api/customCss.ts index b66c034..2d92faa 100644 --- a/editor/frontend/src/api/customCss.ts +++ b/editor/frontend/src/api/customCss.ts @@ -1,13 +1,17 @@ const BASE = '/api/custom-css' -export async function getCustomCss(): Promise { - const res = await fetch(BASE) +export type CssFileName = 'custom' | 'buttons' | 'subtitles' + +export async function getCustomCss(name: CssFileName = 'custom'): Promise { + const url = name === 'custom' ? BASE : `${BASE}/${name}` + const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.text() } -export async function saveCustomCss(content: string): Promise { - const res = await fetch(BASE, { +export async function saveCustomCss(content: string, name: CssFileName = 'custom'): Promise { + const url = name === 'custom' ? BASE : `${BASE}/${name}` + const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: content, diff --git a/editor/frontend/src/api/graph.ts b/editor/frontend/src/api/graph.ts index a7d94a7..2a01971 100644 --- a/editor/frontend/src/api/graph.ts +++ b/editor/frontend/src/api/graph.ts @@ -12,6 +12,7 @@ export interface UpdateNodePayload { name?: string isEnd?: boolean autoContinue?: boolean + loopVideo?: boolean backgroundColor?: string decisionAppearanceConfig?: string musicAssetId?: string | null diff --git a/editor/frontend/src/api/transition.ts b/editor/frontend/src/api/transition.ts index e9be42d..972bf60 100644 --- a/editor/frontend/src/api/transition.ts +++ b/editor/frontend/src/api/transition.ts @@ -13,3 +13,6 @@ export const saveTransitionLayers = (edgeId: string, layers: VideoLayerRequest[] export const saveTransitionAudio = (edgeId: string, tracks: AudioTrackRequest[]) => apiClient.put(`/edges/${edgeId}/transition/audio`, tracks) + +export const setTransitionBackgroundColor = (edgeId: string, backgroundColor: string | null) => + apiClient.put(`/edges/${edgeId}/transition/background-color`, { backgroundColor }) diff --git a/editor/frontend/src/components/editor/CustomCssPanel.tsx b/editor/frontend/src/components/editor/CustomCssPanel.tsx index d999ca1..ec9714b 100644 --- a/editor/frontend/src/components/editor/CustomCssPanel.tsx +++ b/editor/frontend/src/components/editor/CustomCssPanel.tsx @@ -1,7 +1,50 @@ -import { useState, useEffect, useRef } from 'react' -import { getCustomCss, saveCustomCss } from '@/api/customCss' +import { useState, useEffect, useRef, useCallback } from 'react' +import { getCustomCss, saveCustomCss, type CssFileName } from '@/api/customCss' -export default function CustomCssPanel() { +// ── Tab definitions ────────────────────────────────────────────────────────── + +interface TabDef { + key: CssFileName + label: string + description: string + placeholder: string +} + +const TABS: TabDef[] = [ + { + key: 'buttons', + label: 'Buttons', + description: 'Style decision buttons and their container. Loaded as buttons.css.', + placeholder: + '/* Decision button styling */\n\n' + + '.decision-btn {\n border-radius: 20px;\n font-size: 16px;\n}\n\n' + + '.decision-btn:hover {\n background: rgba(255,255,255,0.2);\n}', + }, + { + key: 'subtitles', + label: 'Subtitles', + description: 'Style the subtitle overlay and text. Loaded as subtitles.css.', + placeholder: + '/* Subtitle styling */\n\n' + + '#subtitle-text {\n font-size: 22px;\n background: rgba(0,0,0,0.85);\n color: #ffe066;\n}\n\n' + + '#subtitle-container {\n bottom: 10%;\n}', + }, + { + key: 'custom', + label: 'General', + description: 'General overrides for any runtime element. Loaded last as custom.css.', + placeholder: + '/* General runtime overrides */\n\n' + + ':root {\n --arvexis-accent: #ff6b6b;\n}\n\n' + + '#stage {\n background: #111;\n}', + }, +] + +const REF_TAB_KEY = '__reference__' + +// ── Single-tab editor ──────────────────────────────────────────────────────── + +function CssTabEditor({ tab }: { tab: TabDef }) { const [content, setContent] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -11,18 +54,19 @@ export default function CustomCssPanel() { useEffect(() => { setLoading(true) - getCustomCss() + setError(null) + getCustomCss(tab.key) .then(setContent) .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load')) .finally(() => setLoading(false)) - }, []) + }, [tab.key]) - async function handleSave() { + const handleSave = useCallback(async () => { setSaving(true) setError(null) setSaved(false) try { - await saveCustomCss(content) + await saveCustomCss(content, tab.key) setSaved(true) setTimeout(() => setSaved(false), 3000) } catch (e: unknown) { @@ -30,14 +74,13 @@ export default function CustomCssPanel() { } finally { setSaving(false) } - } + }, [content, tab.key]) function handleKeyDown(e: React.KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() handleSave() } - // Tab inserts 2 spaces if (e.key === 'Tab') { e.preventDefault() const ta = textareaRef.current @@ -47,17 +90,18 @@ export default function CustomCssPanel() { const val = ta.value const newVal = val.substring(0, start) + ' ' + val.substring(end) setContent(newVal) - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = start + 2 - }) + requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + 2 }) } } return ( -
-
-

Custom CSS

-
+
+
+

+ {tab.description}{' '} + Ctrl+S to save. +

+
{saved && Saved ✓} {error && {error}}
- -
-

- Override the default runtime styles. This file is loaded after default.css in the runtime player. - Press Ctrl+S to save. -

-
-
{loading ? (
Loading…
@@ -84,14 +120,196 @@ export default function CustomCssPanel() {