Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public record UpdateProjectConfigRequest(
Double decisionTimeoutSecs,
String defaultLocaleCode,
String defaultBackgroundColor,
Boolean hideDecisionButtons,
Boolean showDecisionInputIndicator,
Boolean ffmpegThreadsAuto,
Integer ffmpegThreads
) {}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ private List<String> buildCompositeCommand(CompositeSpec spec) {
List<VideoLayerSpec> layers = spec.getVideoLayers();
for (VideoLayerSpec layer : layers) {
List<String> 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");
Expand Down Expand Up @@ -227,7 +231,7 @@ private List<String> buildCompositeCommand(CompositeSpec spec) {
}
}

String filterComplex = buildOverlayFilter(layers, tracks);
String filterComplex = buildOverlayFilter(layers, tracks, spec.getOutputResolution());
if (filterComplex != null) {
builder.filterComplex(filterComplex);
builder.mapVideo("[vout]");
Expand Down Expand Up @@ -255,17 +259,29 @@ private List<String> buildCompositeCommand(CompositeSpec spec) {
*
* Returns {@code null} when there are no layers and no tracks (nothing to filter).
*/
private String buildOverlayFilter(List<VideoLayerSpec> layers, List<AudioTrackSpec> tracks) {
private String buildOverlayFilter(List<VideoLayerSpec> layers, List<AudioTrackSpec> tracks,
String outputResolution) {
if (layers.isEmpty() && tracks.isEmpty()) return null;

List<String> 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;
}

Expand All @@ -287,4 +303,14 @@ private String buildOverlayFilter(List<VideoLayerSpec> layers, List<AudioTrackSp

return String.join(";", parts);
}

private String[] parseResolution(String resolution) {
if (resolution == null) return null;
String[] parts = resolution.toLowerCase().split("x", 2);
if (parts.length != 2) return null;
String width = parts[0].trim();
String height = parts[1].trim();
if (width.isEmpty() || height.isEmpty()) return null;
return new String[] { width, height };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,24 @@ public class VideoLayerSpec {
private final String codec;

private final boolean freezeLastFrame;
private final boolean loopLayer;

public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha, boolean freezeLastFrame) {
this(filePath, startAt, order, hasAlpha, freezeLastFrame, null);
this(filePath, startAt, order, hasAlpha, freezeLastFrame, null, false);
}

public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha, boolean freezeLastFrame, String codec) {
this(filePath, startAt, order, hasAlpha, freezeLastFrame, codec, false);
}

public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha, boolean freezeLastFrame, String codec, boolean loopLayer) {
this.filePath = filePath;
this.startAt = startAt;
this.order = order;
this.hasAlpha = hasAlpha;
this.freezeLastFrame = freezeLastFrame;
this.codec = codec;
this.loopLayer = loopLayer;
}

public Path getFilePath() { return filePath; }
Expand All @@ -31,4 +37,5 @@ public VideoLayerSpec(Path filePath, double startAt, int order, boolean hasAlpha
public boolean isHasAlpha() { return hasAlpha; }
public boolean isFreezeLastFrame() { return freezeLastFrame; }
public String getCodec() { return codec; }
public boolean isLoopLayer() { return loopLayer; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class ProjectConfigData {
private double decisionTimeoutSecs;
private String defaultLocaleCode;
private String defaultBackgroundColor;
private boolean hideDecisionButtons;
private boolean showDecisionInputIndicator;
private Integer ffmpegThreads; // null = Auto (let FFmpeg decide)

public ProjectConfigData() {}
Expand Down Expand Up @@ -52,6 +54,12 @@ public ProjectConfigData() {}
public String getDefaultBackgroundColor() { return defaultBackgroundColor; }
public void setDefaultBackgroundColor(String defaultBackgroundColor) { this.defaultBackgroundColor = defaultBackgroundColor; }

public boolean isHideDecisionButtons() { return hideDecisionButtons; }
public void setHideDecisionButtons(boolean hideDecisionButtons) { this.hideDecisionButtons = hideDecisionButtons; }

public boolean isShowDecisionInputIndicator() { return showDecisionInputIndicator; }
public void setShowDecisionInputIndicator(boolean showDecisionInputIndicator) { this.showDecisionInputIndicator = showDecisionInputIndicator; }

public Integer getFfmpegThreads() { return ffmpegThreads; }
public void setFfmpegThreads(Integer ffmpegThreads) { this.ffmpegThreads = ffmpegThreads; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermission;
import java.util.*;
import java.util.stream.Collectors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

Expand Down Expand Up @@ -207,7 +208,9 @@ private void runCompilation(PreviewJob job) {

// ── Stage 5: Package dist/ + create ZIP (92→100%) ──────────────
if (job.isCancelRequested()) { job.markCancelled(); return; }
Path zipPath = buildPackage(job, projectDir, outputBase, manifestFile);
Path sourceAssetsDir = Path.of(config.getAssetsDirectory()).normalize();
Set<String> referencedMusicAssetPaths = collectReferencedMusicAssetPaths(sceneNodes);
Path zipPath = buildPackage(job, projectDir, outputBase, manifestFile, sourceAssetsDir, referencedMusicAssetPaths);

lastZipPath = zipPath;
job.markDone(zipPath.toAbsolutePath().toString());
Expand All @@ -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<String> referencedMusicAssetPaths) throws Exception {
Path distDir = projectDir.resolve("dist");
Files.createDirectories(distDir);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> referencedMusicAssetPaths,
Path zipFile) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(
Files.newOutputStream(zipFile,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) {
Expand Down Expand Up @@ -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<String> collectReferencedMusicAssetPaths(List<Map<String, Object>> 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")
Expand All @@ -389,10 +440,11 @@ private void compileScene(Map<String, Object> node, ProjectConfigData config,
Map<String, Object> 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<AudioTrackSpec> tracks = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private void fillSceneNode(Map<String, Object> n, String nodeId,

// Video layers with relative asset path
List<Map<String, Object>> 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
Expand All @@ -181,6 +181,7 @@ private void fillSceneNode(Map<String, Object> 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));
Expand Down Expand Up @@ -210,7 +211,7 @@ private void fillSceneNode(Map<String, Object> n, String nodeId,

// Decisions
List<Map<String, Object>> 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<Map<String, Object>> decList = new ArrayList<>();
Expand All @@ -219,6 +220,8 @@ private void fillSceneNode(Map<String, Object> 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);
Expand Down Expand Up @@ -406,6 +409,8 @@ private Map<String, Object> 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;
}

Expand All @@ -431,8 +436,9 @@ private boolean intFlag(Object val) {

private double computeSceneDuration(List<Map<String, Object>> layers,
List<Map<String, Object>> audios) {
double max = 0;
double max = -1;
for (Map<String, Object> 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));
Expand All @@ -442,6 +448,14 @@ private double computeSceneDuration(List<Map<String, Object>> layers,
Object start = t.get("startAt");
if (dur != null) max = Math.max(max, toDouble(dur) + toDouble(start));
}
if (max < 0) {
for (Map<String, Object> 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;
}

Expand Down
Loading
Loading