From 81c078d95f0e5c17ee57de2759150410e325717d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 8 Jan 2026 20:10:50 +0100 Subject: [PATCH] Explode assets class into subtypes --- .../imgfloat/config/SchemaMigration.java | 130 ++++- .../dev/kruhlmann/imgfloat/model/Asset.java | 228 +-------- .../kruhlmann/imgfloat/model/AssetPatch.java | 68 +-- .../kruhlmann/imgfloat/model/AssetType.java | 32 ++ .../kruhlmann/imgfloat/model/AssetView.java | 105 +++- .../kruhlmann/imgfloat/model/AudioAsset.java | 144 ++++++ .../kruhlmann/imgfloat/model/ScriptAsset.java | 69 +++ .../imgfloat/model/TransformRequest.java | 30 +- .../kruhlmann/imgfloat/model/VisualAsset.java | 191 +++++++ .../imgfloat/repository/AssetRepository.java | 1 - .../repository/AudioAssetRepository.java | 10 + .../repository/ScriptAssetRepository.java | 10 + .../repository/VisualAssetRepository.java | 11 + .../imgfloat/service/AssetStorageService.java | 39 +- .../service/ChannelDirectoryService.java | 464 ++++++++++++++---- src/main/resources/static/js/admin.js | 77 ++- src/main/resources/static/js/broadcast.js | 6 + src/main/resources/static/js/media/audio.js | 3 + 18 files changed, 1193 insertions(+), 425 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/AssetType.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/AudioAsset.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/VisualAsset.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/AudioAssetRepository.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/VisualAssetRepository.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index 44a36b9..3c59a4b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -24,7 +24,7 @@ public class SchemaMigration implements ApplicationRunner { public void run(ApplicationArguments args) { ensureSessionAttributeUpsertTrigger(); ensureChannelCanvasColumns(); - ensureAssetMediaColumns(); + ensureAssetTables(); ensureAuthorizedClientTable(); normalizeAuthorizedClientTimestamps(); } @@ -67,12 +67,12 @@ public class SchemaMigration implements ApplicationRunner { addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080"); } - private void ensureAssetMediaColumns() { + private void ensureAssetTables() { List columns; try { columns = jdbcTemplate.query("PRAGMA table_info(assets)", (rs, rowNum) -> rs.getString("name")); } catch (DataAccessException ex) { - logger.warn("Unable to inspect assets table for media columns", ex); + logger.warn("Unable to inspect assets table for asset columns", ex); return; } @@ -81,15 +81,121 @@ public class SchemaMigration implements ApplicationRunner { } String table = "assets"; - addColumnIfMissing(table, columns, "speed", "REAL", "1.0"); - addColumnIfMissing(table, columns, "muted", "BOOLEAN", "0"); - addColumnIfMissing(table, columns, "media_type", "TEXT", "'application/octet-stream'"); - addColumnIfMissing(table, columns, "audio_loop", "BOOLEAN", "0"); - addColumnIfMissing(table, columns, "audio_delay_millis", "INTEGER", "0"); - addColumnIfMissing(table, columns, "audio_speed", "REAL", "1.0"); - addColumnIfMissing(table, columns, "audio_pitch", "REAL", "1.0"); - addColumnIfMissing(table, columns, "audio_volume", "REAL", "1.0"); - addColumnIfMissing(table, columns, "preview", "TEXT", "NULL"); + addColumnIfMissing(table, columns, "asset_type", "TEXT", "'OTHER'"); + ensureAssetTypeTables(columns); + } + + private void ensureAssetTypeTables(List assetColumns) { + try { + jdbcTemplate.execute( + """ + CREATE TABLE IF NOT EXISTS visual_assets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + preview TEXT, + x REAL NOT NULL DEFAULT 0, + y REAL NOT NULL DEFAULT 0, + width REAL NOT NULL DEFAULT 0, + height REAL NOT NULL DEFAULT 0, + rotation REAL NOT NULL DEFAULT 0, + speed REAL, + muted BOOLEAN, + media_type TEXT, + original_media_type TEXT, + z_index INTEGER, + audio_volume REAL, + hidden BOOLEAN + ) + """ + ); + jdbcTemplate.execute( + """ + CREATE TABLE IF NOT EXISTS audio_assets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + media_type TEXT, + original_media_type TEXT, + audio_loop BOOLEAN, + audio_delay_millis INTEGER, + audio_speed REAL, + audio_pitch REAL, + audio_volume REAL, + hidden BOOLEAN + ) + """ + ); + jdbcTemplate.execute( + """ + CREATE TABLE IF NOT EXISTS script_assets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + media_type TEXT, + original_media_type TEXT + ) + """ + ); + backfillAssetTypes(assetColumns); + } catch (DataAccessException ex) { + logger.warn("Unable to ensure asset type tables", ex); + } + } + + private void backfillAssetTypes(List assetColumns) { + if (!assetColumns.contains("media_type")) { + return; + } + try { + jdbcTemplate.execute( + """ + UPDATE assets + SET asset_type = CASE + WHEN media_type LIKE 'audio/%' THEN 'AUDIO' + WHEN media_type LIKE 'video/%' THEN 'VIDEO' + WHEN media_type LIKE 'image/%' THEN 'IMAGE' + WHEN media_type LIKE 'application/javascript%' THEN 'SCRIPT' + WHEN media_type LIKE 'text/javascript%' THEN 'SCRIPT' + ELSE COALESCE(asset_type, 'OTHER') + END + WHERE asset_type IS NULL OR asset_type = '' OR asset_type = 'OTHER' + """ + ); + jdbcTemplate.execute( + """ + INSERT OR IGNORE INTO visual_assets ( + id, name, preview, x, y, width, height, rotation, speed, muted, media_type, + original_media_type, z_index, audio_volume, hidden + ) + SELECT id, name, preview, x, y, width, height, rotation, speed, muted, media_type, + original_media_type, z_index, audio_volume, hidden + FROM assets + WHERE asset_type IN ('IMAGE', 'VIDEO', 'OTHER') + """ + ); + jdbcTemplate.execute( + """ + INSERT OR IGNORE INTO audio_assets ( + id, name, media_type, original_media_type, audio_loop, audio_delay_millis, + audio_speed, audio_pitch, audio_volume, hidden + ) + SELECT id, name, media_type, original_media_type, audio_loop, audio_delay_millis, + audio_speed, audio_pitch, audio_volume, hidden + FROM assets + WHERE asset_type = 'AUDIO' + """ + ); + jdbcTemplate.execute( + """ + INSERT OR IGNORE INTO script_assets ( + id, name, media_type, original_media_type + ) + SELECT id, name, media_type, original_media_type + FROM assets + WHERE asset_type = 'SCRIPT' + """ + ); + } catch (DataAccessException ex) { + logger.warn("Unable to backfill asset type tables", ex); + } } private void addColumnIfMissing( diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java index 1a50a6b..66c0556 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java @@ -2,6 +2,8 @@ package dev.kruhlmann.imgfloat.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; @@ -20,14 +22,9 @@ public class Asset { @Column(nullable = false) private String broadcaster; - @Column(nullable = false) - private String name; - - @Column(columnDefinition = "TEXT", nullable = false) - private String url; - - @Column(columnDefinition = "TEXT", nullable = false) - private String preview; + @Enumerated(EnumType.STRING) + @Column(name = "asset_type", nullable = false) + private AssetType assetType; @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -35,39 +32,12 @@ public class Asset { @Column(name = "updated_at", nullable = false) private Instant updatedAt; - private double x; - private double y; - private double width; - private double height; - private double rotation; - private Double speed; - private Boolean muted; - private String mediaType; - private String originalMediaType; - private Integer zIndex; - private Boolean audioLoop; - private Integer audioDelayMillis; - private Double audioSpeed; - private Double audioPitch; - private Double audioVolume; - private boolean hidden; - public Asset() {} - public Asset(String broadcaster, String name, String url, double width, double height) { + public Asset(String broadcaster, AssetType assetType) { this.id = UUID.randomUUID().toString(); this.broadcaster = normalize(broadcaster); - this.name = name; - this.url = url; - this.width = width; - this.height = height; - this.x = 0; - this.y = 0; - this.rotation = 0; - this.speed = 1.0; - this.muted = false; - this.zIndex = 1; - this.hidden = true; + this.assetType = assetType == null ? AssetType.OTHER : assetType; this.createdAt = Instant.now(); this.updatedAt = this.createdAt; } @@ -84,32 +54,8 @@ public class Asset { } this.updatedAt = now; this.broadcaster = normalize(broadcaster); - if (this.name == null || this.name.isBlank()) { - this.name = this.id; - } - if (this.speed == null) { - this.speed = 1.0; - } - if (this.muted == null) { - this.muted = Boolean.FALSE; - } - if (this.zIndex == null || this.zIndex < 1) { - this.zIndex = 1; - } - if (this.audioLoop == null) { - this.audioLoop = Boolean.FALSE; - } - if (this.audioDelayMillis == null) { - this.audioDelayMillis = 0; - } - if (this.audioSpeed == null) { - this.audioSpeed = 1.0; - } - if (this.audioPitch == null) { - this.audioPitch = 1.0; - } - if (this.audioVolume == null) { - this.audioVolume = 1.0; + if (this.assetType == null) { + this.assetType = AssetType.OTHER; } } @@ -125,112 +71,12 @@ public class Asset { this.broadcaster = normalize(broadcaster); } - public String getName() { - return name; + public AssetType getAssetType() { + return assetType == null ? AssetType.OTHER : assetType; } - public void setName(String name) { - this.name = name; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public double getX() { - return x; - } - - public void setX(double x) { - this.x = x; - } - - public double getY() { - return y; - } - - public void setY(double y) { - this.y = y; - } - - public double getWidth() { - return width; - } - - public void setWidth(double width) { - this.width = width; - } - - public double getHeight() { - return height; - } - - public void setHeight(double height) { - this.height = height; - } - - public double getRotation() { - return rotation; - } - - public void setRotation(double rotation) { - this.rotation = rotation; - } - - public double getSpeed() { - return speed == null ? 1.0 : speed; - } - - public void setSpeed(double speed) { - this.speed = speed; - } - - public boolean isMuted() { - return muted != null && muted; - } - - public void setMuted(boolean muted) { - this.muted = muted; - } - - public String getMediaType() { - return mediaType; - } - - public void setMediaType(String mediaType) { - this.mediaType = mediaType; - } - - public String getOriginalMediaType() { - return originalMediaType; - } - - public void setOriginalMediaType(String originalMediaType) { - this.originalMediaType = originalMediaType; - } - - public String getPreview() { - return preview; - } - - public void setPreview(String preview) { - this.preview = preview; - } - - public boolean isVideo() { - return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/"); - } - - public boolean isHidden() { - return hidden; - } - - public void setHidden(boolean hidden) { - this.hidden = hidden; + public void setAssetType(AssetType assetType) { + this.assetType = assetType == null ? AssetType.OTHER : assetType; } public Instant getCreatedAt() { @@ -249,54 +95,6 @@ public class Asset { this.updatedAt = updatedAt; } - public Integer getZIndex() { - return zIndex == null ? 1 : Math.max(1, zIndex); - } - - public void setZIndex(Integer zIndex) { - this.zIndex = zIndex == null ? null : Math.max(1, zIndex); - } - - public boolean isAudioLoop() { - return audioLoop != null && audioLoop; - } - - public void setAudioLoop(Boolean audioLoop) { - this.audioLoop = audioLoop; - } - - public Integer getAudioDelayMillis() { - return audioDelayMillis == null ? 0 : Math.max(0, audioDelayMillis); - } - - public void setAudioDelayMillis(Integer audioDelayMillis) { - this.audioDelayMillis = audioDelayMillis; - } - - public double getAudioSpeed() { - return audioSpeed == null ? 1.0 : Math.max(0.1, audioSpeed); - } - - public void setAudioSpeed(Double audioSpeed) { - this.audioSpeed = audioSpeed; - } - - public double getAudioPitch() { - return audioPitch == null ? 1.0 : Math.max(0.1, audioPitch); - } - - public void setAudioPitch(Double audioPitch) { - this.audioPitch = audioPitch; - } - - public double getAudioVolume() { - return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume)); - } - - public void setAudioVolume(Double audioVolume) { - this.audioVolume = audioVolume; - } - private static String normalize(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java index e6c36d0..bdffe30 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java @@ -24,39 +24,43 @@ public record AssetPatch( Double audioPitch, Double audioVolume ) { - public static TransformSnapshot capture(Asset asset) { - return new TransformSnapshot( - asset.getX(), - asset.getY(), - asset.getWidth(), - asset.getHeight(), - asset.getRotation(), - asset.getSpeed(), - asset.isMuted(), - asset.getZIndex(), - asset.isAudioLoop(), - asset.getAudioDelayMillis(), - asset.getAudioSpeed(), - asset.getAudioPitch(), - asset.getAudioVolume() + /** + * Produces a minimal patch from a visual transform operation. + */ + public static AssetPatch fromVisualTransform(VisualSnapshot before, VisualAsset asset, TransformRequest request) { + return new AssetPatch( + asset.getId(), + request.getX() != null ? changed(before.x(), asset.getX()) : null, + request.getY() != null ? changed(before.y(), asset.getY()) : null, + request.getWidth() != null ? changed(before.width(), asset.getWidth()) : null, + request.getHeight() != null ? changed(before.height(), asset.getHeight()) : null, + request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null, + request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, + request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, + request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, + null, + null, + null, + null, + null, + request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null ); } /** - * Produces a minimal patch from a transform operation. Only fields that changed and were part of - * the incoming request are populated to keep WebSocket payloads small. + * Produces a minimal patch from an audio update operation. */ - public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) { + public static AssetPatch fromAudioTransform(AudioSnapshot before, AudioAsset asset, TransformRequest request) { return new AssetPatch( asset.getId(), - changed(before.x(), asset.getX()), - changed(before.y(), asset.getY()), - changed(before.width(), asset.getWidth()), - changed(before.height(), asset.getHeight()), - changed(before.rotation(), asset.getRotation()), - request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, - request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, - request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, + null, + null, + null, + null, + null, + null, + null, + null, null, request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null, request.getAudioDelayMillis() != null @@ -68,9 +72,9 @@ public record AssetPatch( ); } - public static AssetPatch fromVisibility(Asset asset) { + public static AssetPatch fromVisibility(String assetId, boolean hidden) { return new AssetPatch( - asset.getId(), + assetId, null, null, null, @@ -79,7 +83,7 @@ public record AssetPatch( null, null, null, - asset.isHidden(), + hidden, null, null, null, @@ -100,7 +104,7 @@ public record AssetPatch( return before == after ? null : after; } - public record TransformSnapshot( + public record VisualSnapshot( double x, double y, double width, @@ -109,6 +113,10 @@ public record AssetPatch( double speed, boolean muted, int zIndex, + double audioVolume + ) {} + + public record AudioSnapshot( boolean audioLoop, int audioDelayMillis, double audioSpeed, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetType.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetType.java new file mode 100644 index 0000000..a7b30ae --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetType.java @@ -0,0 +1,32 @@ +package dev.kruhlmann.imgfloat.model; + +import java.util.Locale; + +public enum AssetType { + IMAGE, + VIDEO, + AUDIO, + SCRIPT, + OTHER; + + public static AssetType fromMediaType(String mediaType, String originalMediaType) { + String raw = mediaType != null && !mediaType.isBlank() ? mediaType : originalMediaType; + if (raw == null || raw.isBlank()) { + return OTHER; + } + String normalized = raw.toLowerCase(Locale.ROOT); + if (normalized.startsWith("image/")) { + return IMAGE; + } + if (normalized.startsWith("video/")) { + return VIDEO; + } + if (normalized.startsWith("audio/")) { + return AUDIO; + } + if (normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript")) { + return SCRIPT; + } + return OTHER; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java index c54c2c4..efb9946 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java @@ -17,6 +17,7 @@ public record AssetView( Boolean muted, String mediaType, String originalMediaType, + AssetType assetType, Integer zIndex, Boolean audioLoop, Integer audioDelayMillis, @@ -28,32 +29,92 @@ public record AssetView( Instant createdAt, Instant updatedAt ) { - public static AssetView from(String broadcaster, Asset asset) { + public static AssetView fromVisual(String broadcaster, Asset asset, VisualAsset visual) { + boolean hasPreview = visual.getPreview() != null && !visual.getPreview().isBlank(); return new AssetView( asset.getId(), asset.getBroadcaster(), - asset.getName(), + visual.getName(), "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", - asset.getPreview() != null && !asset.getPreview().isBlank() - ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" - : null, - asset.getX(), - asset.getY(), - asset.getWidth(), - asset.getHeight(), - asset.getRotation(), - asset.getSpeed(), - asset.isMuted(), - asset.getMediaType(), - asset.getOriginalMediaType(), - asset.getZIndex(), - asset.isAudioLoop(), - asset.getAudioDelayMillis(), - asset.getAudioSpeed(), - asset.getAudioPitch(), - asset.getAudioVolume(), - asset.isHidden(), - asset.getPreview() != null && !asset.getPreview().isBlank(), + hasPreview ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null, + visual.getX(), + visual.getY(), + visual.getWidth(), + visual.getHeight(), + visual.getRotation(), + visual.getSpeed(), + visual.isMuted(), + visual.getMediaType(), + visual.getOriginalMediaType(), + asset.getAssetType(), + visual.getZIndex(), + null, + null, + null, + null, + visual.getAudioVolume(), + visual.isHidden(), + hasPreview, + asset.getCreatedAt(), + asset.getUpdatedAt() + ); + } + + public static AssetView fromAudio(String broadcaster, Asset asset, AudioAsset audio) { + return new AssetView( + asset.getId(), + asset.getBroadcaster(), + audio.getName(), + "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", + null, + 0, + 0, + 0, + 0, + 0, + null, + null, + audio.getMediaType(), + audio.getOriginalMediaType(), + asset.getAssetType(), + null, + audio.isAudioLoop(), + audio.getAudioDelayMillis(), + audio.getAudioSpeed(), + audio.getAudioPitch(), + audio.getAudioVolume(), + audio.isHidden(), + false, + asset.getCreatedAt(), + asset.getUpdatedAt() + ); + } + + public static AssetView fromScript(String broadcaster, Asset asset, ScriptAsset script) { + return new AssetView( + asset.getId(), + asset.getBroadcaster(), + script.getName(), + "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", + null, + 0, + 0, + 0, + 0, + 0, + null, + null, + script.getMediaType(), + script.getOriginalMediaType(), + asset.getAssetType(), + null, + null, + null, + null, + null, + null, + false, + false, asset.getCreatedAt(), asset.getUpdatedAt() ); diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AudioAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/AudioAsset.java new file mode 100644 index 0000000..97f485f --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AudioAsset.java @@ -0,0 +1,144 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "audio_assets") +public class AudioAsset { + + @Id + private String id; + + @Column(nullable = false) + private String name; + + private String mediaType; + private String originalMediaType; + private Boolean audioLoop; + private Integer audioDelayMillis; + private Double audioSpeed; + private Double audioPitch; + private Double audioVolume; + private boolean hidden; + + public AudioAsset() {} + + public AudioAsset(String assetId, String name) { + this.id = assetId; + this.name = name; + this.audioLoop = Boolean.FALSE; + this.audioDelayMillis = 0; + this.audioSpeed = 1.0; + this.audioPitch = 1.0; + this.audioVolume = 1.0; + this.hidden = true; + } + + @PrePersist + @PreUpdate + public void prepare() { + if (this.audioLoop == null) { + this.audioLoop = Boolean.FALSE; + } + if (this.audioDelayMillis == null) { + this.audioDelayMillis = 0; + } + if (this.audioSpeed == null) { + this.audioSpeed = 1.0; + } + if (this.audioPitch == null) { + this.audioPitch = 1.0; + } + if (this.audioVolume == null) { + this.audioVolume = 1.0; + } + if (this.name == null || this.name.isBlank()) { + this.name = this.id; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } + + public boolean isAudioLoop() { + return audioLoop != null && audioLoop; + } + + public void setAudioLoop(Boolean audioLoop) { + this.audioLoop = audioLoop; + } + + public Integer getAudioDelayMillis() { + return audioDelayMillis == null ? 0 : Math.max(0, audioDelayMillis); + } + + public void setAudioDelayMillis(Integer audioDelayMillis) { + this.audioDelayMillis = audioDelayMillis; + } + + public double getAudioSpeed() { + return audioSpeed == null ? 1.0 : Math.max(0.1, audioSpeed); + } + + public void setAudioSpeed(Double audioSpeed) { + this.audioSpeed = audioSpeed; + } + + public double getAudioPitch() { + return audioPitch == null ? 1.0 : Math.max(0.1, audioPitch); + } + + public void setAudioPitch(Double audioPitch) { + this.audioPitch = audioPitch; + } + + public double getAudioVolume() { + return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume)); + } + + public void setAudioVolume(Double audioVolume) { + this.audioVolume = audioVolume; + } + + public boolean isHidden() { + return hidden; + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java new file mode 100644 index 0000000..d0f06c4 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java @@ -0,0 +1,69 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "script_assets") +public class ScriptAsset { + + @Id + private String id; + + @Column(nullable = false) + private String name; + + private String mediaType; + private String originalMediaType; + + public ScriptAsset() {} + + public ScriptAsset(String assetId, String name) { + this.id = assetId; + this.name = name; + } + + @PrePersist + @PreUpdate + public void prepare() { + if (this.name == null || this.name.isBlank()) { + this.name = this.id; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java index 35eaf99..4b99154 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java @@ -7,16 +7,16 @@ import jakarta.validation.constraints.PositiveOrZero; public class TransformRequest { - private double x; - private double y; + private Double x; + private Double y; @Positive(message = "Width must be greater than 0") - private double width; + private Double width; @Positive(message = "Height must be greater than 0") - private double height; + private Double height; - private double rotation; + private Double rotation; @DecimalMin(value = "0.0", message = "Playback speed cannot be negative") @DecimalMax(value = "4.0", message = "Playback speed cannot exceed 4.0") @@ -44,43 +44,43 @@ public class TransformRequest { @DecimalMax(value = "1.0", message = "Audio volume cannot exceed 1.0") private Double audioVolume; - public double getX() { + public Double getX() { return x; } - public void setX(double x) { + public void setX(Double x) { this.x = x; } - public double getY() { + public Double getY() { return y; } - public void setY(double y) { + public void setY(Double y) { this.y = y; } - public double getWidth() { + public Double getWidth() { return width; } - public void setWidth(double width) { + public void setWidth(Double width) { this.width = width; } - public double getHeight() { + public Double getHeight() { return height; } - public void setHeight(double height) { + public void setHeight(Double height) { this.height = height; } - public double getRotation() { + public Double getRotation() { return rotation; } - public void setRotation(double rotation) { + public void setRotation(Double rotation) { this.rotation = rotation; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/VisualAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/VisualAsset.java new file mode 100644 index 0000000..0ec1d3e --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/VisualAsset.java @@ -0,0 +1,191 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "visual_assets") +public class VisualAsset { + + @Id + private String id; + + @Column(nullable = false) + private String name; + + private String preview; + + private double x; + private double y; + private double width; + private double height; + private double rotation; + private Double speed; + private Boolean muted; + private String mediaType; + private String originalMediaType; + private Integer zIndex; + private Double audioVolume; + private boolean hidden; + + public VisualAsset() {} + + public VisualAsset(String assetId, String name, double width, double height) { + this.id = assetId; + this.name = name; + this.width = width; + this.height = height; + this.x = 0; + this.y = 0; + this.rotation = 0; + this.speed = 1.0; + this.muted = false; + this.zIndex = 1; + this.audioVolume = 1.0; + this.hidden = true; + } + + @PrePersist + @PreUpdate + public void prepare() { + if (this.speed == null) { + this.speed = 1.0; + } + if (this.muted == null) { + this.muted = Boolean.FALSE; + } + if (this.zIndex == null || this.zIndex < 1) { + this.zIndex = 1; + } + if (this.audioVolume == null) { + this.audioVolume = 1.0; + } + if (this.name == null || this.name.isBlank()) { + this.name = this.id; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPreview() { + return preview; + } + + public void setPreview(String preview) { + this.preview = preview; + } + + public double getX() { + return x; + } + + public void setX(double x) { + this.x = x; + } + + public double getY() { + return y; + } + + public void setY(double y) { + this.y = y; + } + + public double getWidth() { + return width; + } + + public void setWidth(double width) { + this.width = width; + } + + public double getHeight() { + return height; + } + + public void setHeight(double height) { + this.height = height; + } + + public double getRotation() { + return rotation; + } + + public void setRotation(double rotation) { + this.rotation = rotation; + } + + public double getSpeed() { + return speed == null ? 1.0 : speed; + } + + public void setSpeed(Double speed) { + this.speed = speed; + } + + public boolean isMuted() { + return muted != null && muted; + } + + public void setMuted(Boolean muted) { + this.muted = muted; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } + + public Integer getZIndex() { + return zIndex == null ? 1 : Math.max(1, zIndex); + } + + public void setZIndex(Integer zIndex) { + this.zIndex = zIndex == null ? null : Math.max(1, zIndex); + } + + public double getAudioVolume() { + return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume)); + } + + public void setAudioVolume(Double audioVolume) { + this.audioVolume = audioVolume; + } + + public boolean isHidden() { + return hidden; + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java index c65ebce..d5cabf1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java @@ -6,5 +6,4 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface AssetRepository extends JpaRepository { List findByBroadcaster(String broadcaster); - List findByBroadcasterAndHiddenFalse(String broadcaster); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AudioAssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/AudioAssetRepository.java new file mode 100644 index 0000000..0421a7f --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/AudioAssetRepository.java @@ -0,0 +1,10 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.AudioAsset; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AudioAssetRepository extends JpaRepository { + List findByIdIn(Collection ids); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java new file mode 100644 index 0000000..5442e7e --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java @@ -0,0 +1,10 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.ScriptAsset; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScriptAssetRepository extends JpaRepository { + List findByIdIn(Collection ids); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/VisualAssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/VisualAssetRepository.java new file mode 100644 index 0000000..8e4ec90 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/VisualAssetRepository.java @@ -0,0 +1,11 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.VisualAsset; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VisualAssetRepository extends JpaRepository { + List findByIdIn(Collection ids); + List findByIdInAndHiddenFalse(Collection ids); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index 0a42b51..debc8c9 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -1,6 +1,5 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.service.media.AssetContent; import java.io.IOException; import java.nio.file.*; @@ -97,53 +96,57 @@ public class AssetStorageService { logger.info("Wrote asset to {}", file); } - public Optional loadAssetFile(Asset asset) { + public Optional loadAssetFile(String broadcaster, String assetId, String mediaType) { try { - Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()); + Path file = assetPath(broadcaster, assetId, mediaType); if (!Files.exists(file)) return Optional.empty(); byte[] bytes = Files.readAllBytes(file); - return Optional.of(new AssetContent(bytes, asset.getMediaType())); + return Optional.of(new AssetContent(bytes, mediaType)); } catch (Exception e) { - logger.warn("Failed to load asset {}", asset.getId(), e); + logger.warn("Failed to load asset {}", assetId, e); return Optional.empty(); } } - public Optional loadPreview(Asset asset) { + public Optional loadPreview(String broadcaster, String assetId) { try { - Path file = previewPath(asset.getBroadcaster(), asset.getId()); + Path file = previewPath(broadcaster, assetId); if (!Files.exists(file)) return Optional.empty(); byte[] bytes = Files.readAllBytes(file); return Optional.of(new AssetContent(bytes, "image/png")); } catch (Exception e) { - logger.warn("Failed to load preview {}", asset.getId(), e); + logger.warn("Failed to load preview {}", assetId, e); return Optional.empty(); } } - public Optional loadAssetFileSafely(Asset asset) { - if (asset.getUrl() == null) { + public Optional loadAssetFileSafely(String broadcaster, String assetId, String mediaType) { + if (mediaType == null) { return Optional.empty(); } - return loadAssetFile(asset); + return loadAssetFile(broadcaster, assetId, mediaType); } - public Optional loadPreviewSafely(Asset asset) { - if (asset.getPreview() == null) { + public Optional loadPreviewSafely(String broadcaster, String assetId, boolean hasPreview) { + if (!hasPreview) { return Optional.empty(); } - return loadPreview(asset); + return loadPreview(broadcaster, assetId); } - public void deleteAsset(Asset asset) { + public void deleteAsset(String broadcaster, String assetId, String mediaType, boolean hasPreview) { try { - Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())); - Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId())); + if (mediaType != null) { + Files.deleteIfExists(assetPath(broadcaster, assetId, mediaType)); + } + if (hasPreview) { + Files.deleteIfExists(previewPath(broadcaster, assetId)); + } } catch (Exception e) { - logger.warn("Failed to delete asset {}", asset.getId(), e); + logger.warn("Failed to delete asset {}", assetId, e); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 8dad070..291c707 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -6,17 +6,24 @@ import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.AssetEvent; import dev.kruhlmann.imgfloat.model.AssetPatch; +import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.AssetView; +import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest; +import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; +import dev.kruhlmann.imgfloat.model.VisualAsset; import dev.kruhlmann.imgfloat.repository.AssetRepository; +import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; +import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; @@ -25,6 +32,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -42,6 +50,9 @@ public class ChannelDirectoryService { private final ChannelRepository channelRepository; private final AssetRepository assetRepository; + private final VisualAssetRepository visualAssetRepository; + private final AudioAssetRepository audioAssetRepository; + private final ScriptAssetRepository scriptAssetRepository; private final SimpMessagingTemplate messagingTemplate; private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; @@ -53,6 +64,9 @@ public class ChannelDirectoryService { public ChannelDirectoryService( ChannelRepository channelRepository, AssetRepository assetRepository, + VisualAssetRepository visualAssetRepository, + AudioAssetRepository audioAssetRepository, + ScriptAssetRepository scriptAssetRepository, SimpMessagingTemplate messagingTemplate, AssetStorageService assetStorageService, MediaDetectionService mediaDetectionService, @@ -62,6 +76,9 @@ public class ChannelDirectoryService { ) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; + this.visualAssetRepository = visualAssetRepository; + this.audioAssetRepository = audioAssetRepository; + this.scriptAssetRepository = scriptAssetRepository; this.messagingTemplate = messagingTemplate; this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; @@ -111,7 +128,28 @@ public class ChannelDirectoryService { public Collection getVisibleAssets(String broadcaster) { String normalized = normalize(broadcaster); - return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized)); + List assets = assetRepository.findByBroadcaster(normalized); + List visualIds = assets + .stream() + .filter( + (asset) -> + asset.getAssetType() == AssetType.IMAGE || + asset.getAssetType() == AssetType.VIDEO || + asset.getAssetType() == AssetType.OTHER + ) + .map(Asset::getId) + .toList(); + Map assetById = assets.stream().collect(Collectors.toMap(Asset::getId, (asset) -> asset)); + return visualAssetRepository + .findByIdInAndHiddenFalse(visualIds) + .stream() + .map((visual) -> { + Asset asset = assetById.get(visual.getId()); + return asset == null ? null : AssetView.fromVisual(normalized, asset, visual); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparingInt(AssetView::zIndex)) + .toList(); } public CanvasSettingsRequest getCanvasSettings(String broadcaster) { @@ -159,14 +197,8 @@ public class ChannelDirectoryService { boolean isAudio = optimized.mediaType().startsWith("audio/"); boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType); - double defaultWidth = isAudio ? 400 : isCode ? 480 : 640; - double defaultHeight = isAudio ? 80 : isCode ? 270 : 360; - double width = optimized.width() > 0 ? optimized.width() : defaultWidth; - double height = optimized.height() > 0 ? optimized.height() : defaultHeight; - - Asset asset = new Asset(channel.getBroadcaster(), safeName, "", width, height); - asset.setOriginalMediaType(mediaType); - asset.setMediaType(optimized.mediaType()); + AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); + Asset asset = new Asset(channel.getBroadcaster(), assetType); assetStorageService.storeAsset( channel.getBroadcaster(), @@ -175,21 +207,37 @@ public class ChannelDirectoryService { optimized.mediaType() ); - assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()); - asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); + AssetView view; + asset = assetRepository.save(asset); - asset.setSpeed(1.0); - asset.setMuted(optimized.mediaType().startsWith("video/")); - asset.setAudioLoop(false); - asset.setAudioDelayMillis(0); - asset.setAudioSpeed(1.0); - asset.setAudioPitch(1.0); - asset.setAudioVolume(1.0); - asset.setZIndex(nextZIndex(channel.getBroadcaster())); + if (isAudio) { + AudioAsset audio = new AudioAsset(asset.getId(), safeName); + audio.setMediaType(optimized.mediaType()); + audio.setOriginalMediaType(mediaType); + audioAssetRepository.save(audio); + view = AssetView.fromAudio(channel.getBroadcaster(), asset, audio); + } else if (isCode) { + ScriptAsset script = new ScriptAsset(asset.getId(), safeName); + script.setMediaType(optimized.mediaType()); + script.setOriginalMediaType(mediaType); + scriptAssetRepository.save(script); + view = AssetView.fromScript(channel.getBroadcaster(), asset, script); + } else { + double defaultWidth = 640; + double defaultHeight = 360; + double width = optimized.width() > 0 ? optimized.width() : defaultWidth; + double height = optimized.height() > 0 ? optimized.height() : defaultHeight; + VisualAsset visual = new VisualAsset(asset.getId(), safeName, width, height); + visual.setOriginalMediaType(mediaType); + visual.setMediaType(optimized.mediaType()); + visual.setMuted(optimized.mediaType().startsWith("video/")); + visual.setZIndex(nextZIndex(channel.getBroadcaster())); + assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()); + visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); + visualAssetRepository.save(visual); + view = AssetView.fromVisual(channel.getBroadcaster(), asset, visual); + } - assetRepository.save(asset); - - AssetView view = AssetView.from(channel.getBroadcaster(), asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); return Optional.of(view); @@ -201,18 +249,7 @@ public class ChannelDirectoryService { byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); enforceUploadLimit(bytes.length); - Asset asset = new Asset(channel.getBroadcaster(), request.getName().trim(), "", 480, 270); - asset.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); - asset.setMediaType(DEFAULT_CODE_MEDIA_TYPE); - asset.setPreview(""); - asset.setSpeed(1.0); - asset.setMuted(false); - asset.setAudioLoop(false); - asset.setAudioDelayMillis(0); - asset.setAudioSpeed(1.0); - asset.setAudioPitch(1.0); - asset.setAudioVolume(1.0); - asset.setZIndex(nextZIndex(channel.getBroadcaster())); + Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT); try { assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); @@ -220,8 +257,12 @@ public class ChannelDirectoryService { throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); } - assetRepository.save(asset); - AssetView view = AssetView.from(channel.getBroadcaster(), asset); + asset = assetRepository.save(asset); + ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim()); + script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); + script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + scriptAssetRepository.save(script); + AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); return Optional.of(view); } @@ -236,19 +277,23 @@ public class ChannelDirectoryService { .findById(assetId) .filter((asset) -> normalized.equals(asset.getBroadcaster())) .map((asset) -> { - if (!isCodeMediaType(asset.getMediaType()) && !isCodeMediaType(asset.getOriginalMediaType())) { + if (asset.getAssetType() != AssetType.SCRIPT) { throw new ResponseStatusException(BAD_REQUEST, "Asset is not a script"); } - asset.setName(request.getName().trim()); - asset.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); - asset.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + script.setName(request.getName().trim()); + script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); + script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); try { assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); } catch (IOException e) { throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); } assetRepository.save(asset); - AssetView view = AssetView.from(normalized, asset); + scriptAssetRepository.save(script); + AssetView view = AssetView.fromScript(normalized, asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); return view; }); @@ -266,28 +311,69 @@ public class ChannelDirectoryService { .findById(assetId) .filter((asset) -> normalized.equals(asset.getBroadcaster())) .map((asset) -> { - AssetPatch.TransformSnapshot before = AssetPatch.capture(asset); - validateTransform(req); + if (asset.getAssetType() == AssetType.AUDIO) { + AudioAsset audio = audioAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not audio")); + AssetPatch.AudioSnapshot before = new AssetPatch.AudioSnapshot( + audio.isAudioLoop(), + audio.getAudioDelayMillis(), + audio.getAudioSpeed(), + audio.getAudioPitch(), + audio.getAudioVolume() + ); + validateAudioTransform(req); + if (req.getAudioLoop() != null) audio.setAudioLoop(req.getAudioLoop()); + if (req.getAudioDelayMillis() != null) audio.setAudioDelayMillis(req.getAudioDelayMillis()); + if (req.getAudioSpeed() != null) audio.setAudioSpeed(req.getAudioSpeed()); + if (req.getAudioPitch() != null) audio.setAudioPitch(req.getAudioPitch()); + if (req.getAudioVolume() != null) audio.setAudioVolume(req.getAudioVolume()); + audioAssetRepository.save(audio); + AssetView view = AssetView.fromAudio(normalized, asset, audio); + AssetPatch patch = AssetPatch.fromAudioTransform(before, audio, req); + if (hasPatchChanges(patch)) { + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + } + return view; + } - asset.setX(req.getX()); - asset.setY(req.getY()); - asset.setWidth(req.getWidth()); - asset.setHeight(req.getHeight()); - asset.setRotation(req.getRotation()); + if (asset.getAssetType() == AssetType.SCRIPT) { + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + return AssetView.fromScript(normalized, asset, script); + } - if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); - if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); - if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); - if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); - if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); - if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); - if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); - if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); + VisualAsset visual = visualAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual")); + AssetPatch.VisualSnapshot before = new AssetPatch.VisualSnapshot( + visual.getX(), + visual.getY(), + visual.getWidth(), + visual.getHeight(), + visual.getRotation(), + visual.getSpeed(), + visual.isMuted(), + visual.getZIndex(), + visual.getAudioVolume() + ); + validateVisualTransform(req); - assetRepository.save(asset); + if (req.getX() != null) visual.setX(req.getX()); + if (req.getY() != null) visual.setY(req.getY()); + if (req.getWidth() != null) visual.setWidth(req.getWidth()); + if (req.getHeight() != null) visual.setHeight(req.getHeight()); + if (req.getRotation() != null) visual.setRotation(req.getRotation()); + if (req.getZIndex() != null) visual.setZIndex(req.getZIndex()); + if (req.getSpeed() != null) visual.setSpeed(req.getSpeed()); + if (req.getMuted() != null) visual.setMuted(req.getMuted()); + if (req.getAudioVolume() != null) visual.setAudioVolume(req.getAudioVolume()); - AssetView view = AssetView.from(normalized, asset); - AssetPatch patch = AssetPatch.fromTransform(before, asset, req); + visualAssetRepository.save(visual); + + AssetView view = AssetView.fromVisual(normalized, asset, visual); + AssetPatch patch = AssetPatch.fromVisualTransform(before, visual, req); if (hasPatchChanges(patch)) { messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); } @@ -295,21 +381,21 @@ public class ChannelDirectoryService { }); } - private void validateTransform(TransformRequest req) { + private void validateVisualTransform(TransformRequest req) { Settings settings = settingsService.get(); double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); double minSpeed = settings.getMinAssetPlaybackSpeedFraction(); - double minPitch = settings.getMinAssetAudioPitchFraction(); - double maxPitch = settings.getMaxAssetAudioPitchFraction(); double minVolume = settings.getMinAssetVolumeFraction(); double maxVolume = settings.getMaxAssetVolumeFraction(); int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); - if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) throw new ResponseStatusException( + if ( + req.getWidth() == null || req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels + ) throw new ResponseStatusException( BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]" ); - if (req.getHeight() <= 0) throw new ResponseStatusException( + if (req.getHeight() == null || req.getHeight() <= 0) throw new ResponseStatusException( BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]" ); @@ -320,6 +406,23 @@ public class ChannelDirectoryService { BAD_REQUEST, "zIndex must be >= 1" ); + if ( + req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume) + ) throw new ResponseStatusException( + BAD_REQUEST, + "Audio volume out of range [" + minVolume + " to " + maxVolume + "]" + ); + } + + private void validateAudioTransform(TransformRequest req) { + Settings settings = settingsService.get(); + double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); + double minSpeed = settings.getMinAssetPlaybackSpeedFraction(); + double minPitch = settings.getMinAssetAudioPitchFraction(); + double maxPitch = settings.getMaxAssetAudioPitchFraction(); + double minVolume = settings.getMinAssetVolumeFraction(); + double maxVolume = settings.getMaxAssetVolumeFraction(); + if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) throw new ResponseStatusException( BAD_REQUEST, "Audio delay >= 0" @@ -344,7 +447,10 @@ public class ChannelDirectoryService { .findById(assetId) .filter((a) -> normalized.equals(a.getBroadcaster())) .map((asset) -> { - AssetView view = AssetView.from(normalized, asset); + AssetView view = resolveAssetView(normalized, asset); + if (view == null) { + throw new ResponseStatusException(BAD_REQUEST, "Asset data missing"); + } boolean play = req == null || req.getPlay(); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play)); return view; @@ -357,16 +463,43 @@ public class ChannelDirectoryService { .findById(assetId) .filter((a) -> normalized.equals(a.getBroadcaster())) .map((asset) -> { - boolean wasHidden = asset.isHidden(); boolean hidden = request.isHidden(); - if (wasHidden == hidden) { - return AssetView.from(normalized, asset); + if (asset.getAssetType() == AssetType.AUDIO) { + AudioAsset audio = audioAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not audio")); + if (audio.isHidden() == hidden) { + return AssetView.fromAudio(normalized, asset, audio); + } + audio.setHidden(hidden); + audioAssetRepository.save(audio); + AssetView view = AssetView.fromAudio(normalized, asset, audio); + AssetPatch patch = AssetPatch.fromVisibility(asset.getId(), hidden); + AssetView payload = hidden ? null : view; + messagingTemplate.convertAndSend( + topicFor(broadcaster), + AssetEvent.visibility(broadcaster, patch, payload) + ); + return view; } - asset.setHidden(hidden); - assetRepository.save(asset); - AssetView view = AssetView.from(normalized, asset); - AssetPatch patch = AssetPatch.fromVisibility(asset); + if (asset.getAssetType() == AssetType.SCRIPT) { + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + return AssetView.fromScript(normalized, asset, script); + } + + VisualAsset visual = visualAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual")); + if (visual.isHidden() == hidden) { + return AssetView.fromVisual(normalized, asset, visual); + } + visual.setHidden(hidden); + visualAssetRepository.save(visual); + AssetView view = AssetView.fromVisual(normalized, asset, visual); + AssetPatch patch = AssetPatch.fromVisibility(asset.getId(), hidden); AssetView payload = hidden ? null : view; messagingTemplate.convertAndSend( topicFor(broadcaster), @@ -380,8 +513,13 @@ public class ChannelDirectoryService { return assetRepository .findById(assetId) .map((asset) -> { + deleteAssetStorage(asset); + switch (asset.getAssetType()) { + case AUDIO -> audioAssetRepository.deleteById(asset.getId()); + case SCRIPT -> scriptAssetRepository.deleteById(asset.getId()); + default -> visualAssetRepository.deleteById(asset.getId()); + } assetRepository.delete(asset); - assetStorageService.deleteAsset(asset); messagingTemplate.convertAndSend( topicFor(asset.getBroadcaster()), AssetEvent.deleted(asset.getBroadcaster(), assetId) @@ -392,14 +530,11 @@ public class ChannelDirectoryService { } public Optional getAssetContent(String assetId) { - return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely); + return assetRepository.findById(assetId).flatMap(this::loadAssetContent); } public Optional getAssetPreview(String assetId, boolean includeHidden) { - return assetRepository - .findById(assetId) - .filter((a) -> includeHidden || !a.isHidden()) - .flatMap(assetStorageService::loadPreviewSafely); + return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden)); } public boolean isAdmin(String broadcaster, String username) { @@ -457,30 +592,187 @@ public class ChannelDirectoryService { } private List sortAndMapAssets(String broadcaster, Collection assets) { + List audioIds = assets + .stream() + .filter((asset) -> asset.getAssetType() == AssetType.AUDIO) + .map(Asset::getId) + .toList(); + List scriptIds = assets + .stream() + .filter((asset) -> asset.getAssetType() == AssetType.SCRIPT) + .map(Asset::getId) + .toList(); + List visualIds = assets + .stream() + .filter( + (asset) -> + asset.getAssetType() == AssetType.IMAGE || + asset.getAssetType() == AssetType.VIDEO || + asset.getAssetType() == AssetType.OTHER + ) + .map(Asset::getId) + .toList(); + + Map visuals = visualAssetRepository + .findByIdIn(visualIds) + .stream() + .collect(Collectors.toMap(VisualAsset::getId, (asset) -> asset)); + Map audios = audioAssetRepository + .findByIdIn(audioIds) + .stream() + .collect(Collectors.toMap(AudioAsset::getId, (asset) -> asset)); + Map scripts = scriptAssetRepository + .findByIdIn(scriptIds) + .stream() + .collect(Collectors.toMap(ScriptAsset::getId, (asset) -> asset)); + return assets .stream() + .map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts)) + .filter(Objects::nonNull) .sorted( - Comparator.comparingInt(Asset::getZIndex).thenComparing( - Asset::getCreatedAt, - Comparator.nullsFirst(Comparator.naturalOrder()) - ) + Comparator.comparing((AssetView view) -> + view.zIndex() == null ? Integer.MAX_VALUE : view.zIndex() + ).thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder())) ) - .map((a) -> AssetView.from(broadcaster, a)) .toList(); } private int nextZIndex(String broadcaster) { return ( - assetRepository - .findByBroadcaster(normalize(broadcaster)) + visualAssetRepository + .findByIdIn(assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.OTHER)) .stream() - .mapToInt(Asset::getZIndex) + .mapToInt(VisualAsset::getZIndex) .max() .orElse(0) + 1 ); } + private List assetsWithType(String broadcaster, AssetType... types) { + Set typeSet = EnumSet.noneOf(AssetType.class); + typeSet.addAll(Arrays.asList(types)); + return assetRepository + .findByBroadcaster(normalize(broadcaster)) + .stream() + .filter((asset) -> typeSet.contains(asset.getAssetType())) + .map(Asset::getId) + .toList(); + } + + private AssetView resolveAssetView(String broadcaster, Asset asset) { + return resolveAssetView(broadcaster, asset, null, null, null); + } + + private AssetView resolveAssetView( + String broadcaster, + Asset asset, + Map visuals, + Map audios, + Map scripts + ) { + if (asset.getAssetType() == AssetType.AUDIO) { + AudioAsset audio = audios != null + ? audios.get(asset.getId()) + : audioAssetRepository.findById(asset.getId()).orElse(null); + return audio == null ? null : AssetView.fromAudio(broadcaster, asset, audio); + } + if (asset.getAssetType() == AssetType.SCRIPT) { + ScriptAsset script = scripts != null + ? scripts.get(asset.getId()) + : scriptAssetRepository.findById(asset.getId()).orElse(null); + return script == null ? null : AssetView.fromScript(broadcaster, asset, script); + } + VisualAsset visual = visuals != null + ? visuals.get(asset.getId()) + : visualAssetRepository.findById(asset.getId()).orElse(null); + return visual == null ? null : AssetView.fromVisual(broadcaster, asset, visual); + } + + private Optional loadAssetContent(Asset asset) { + switch (asset.getAssetType()) { + case AUDIO -> { + return audioAssetRepository + .findById(asset.getId()) + .flatMap((audio) -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + asset.getId(), + audio.getMediaType() + ) + ); + } + case SCRIPT -> { + return scriptAssetRepository + .findById(asset.getId()) + .flatMap((script) -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + asset.getId(), + script.getMediaType() + ) + ); + } + default -> { + return visualAssetRepository + .findById(asset.getId()) + .flatMap((visual) -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + asset.getId(), + visual.getMediaType() + ) + ); + } + } + } + + private Optional loadAssetPreview(Asset asset, boolean includeHidden) { + if ( + asset.getAssetType() != AssetType.VIDEO && + asset.getAssetType() != AssetType.IMAGE && + asset.getAssetType() != AssetType.OTHER + ) { + return Optional.empty(); + } + return visualAssetRepository + .findById(asset.getId()) + .filter((visual) -> includeHidden || !visual.isHidden()) + .flatMap((visual) -> + assetStorageService.loadPreviewSafely( + asset.getBroadcaster(), + asset.getId(), + visual.getPreview() != null && !visual.getPreview().isBlank() + ) + ); + } + + private void deleteAssetStorage(Asset asset) { + switch (asset.getAssetType()) { + case AUDIO -> audioAssetRepository + .findById(asset.getId()) + .ifPresent((audio) -> + assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), audio.getMediaType(), false) + ); + case SCRIPT -> scriptAssetRepository + .findById(asset.getId()) + .ifPresent((script) -> + assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false) + ); + default -> visualAssetRepository + .findById(asset.getId()) + .ifPresent((visual) -> + assetStorageService.deleteAsset( + asset.getBroadcaster(), + asset.getId(), + visual.getMediaType(), + visual.getPreview() != null && !visual.getPreview().isBlank() + ) + ); + } + } + private boolean hasPatchChanges(AssetPatch patch) { return ( patch.x() != null || diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 3dc92c0..f1e748e 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -947,11 +947,17 @@ function updateHoverCursor(point) { } function isVideoAsset(asset) { + if (asset?.assetType) { + return asset.assetType === "VIDEO"; + } const type = asset?.mediaType || asset?.originalMediaType || ""; return type.startsWith("video/"); } function isCodeAsset(asset) { + if (asset?.assetType) { + return asset.assetType === "SCRIPT"; + } const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); return type.startsWith("application/javascript") || type.startsWith("text/javascript"); } @@ -972,16 +978,31 @@ function isVideoElement(element) { return element && element.tagName === "VIDEO"; } -function getDisplayMediaType(asset) { - const raw = asset.originalMediaType || asset.mediaType || ""; - if (!raw) { - return "Unknown"; +function getAssetTypeLabel(asset) { + const type = asset?.assetType; + if (type) { + const lookup = { + IMAGE: "Image", + VIDEO: "Video", + AUDIO: "Audio", + SCRIPT: "Script", + OTHER: "Other", + }; + return lookup[type] || "Other"; } if (isCodeAsset(asset)) { - return "JavaScript"; + return "Script"; + } + const raw = asset?.originalMediaType || asset?.mediaType || ""; + if (!raw) { + return "Other"; } const parts = raw.split("/"); - return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); + return parts.length > 1 ? parts[0].toUpperCase() : raw.toUpperCase(); +} + +function getDisplayMediaType(asset) { + return getAssetTypeLabel(asset); } function isGifAsset(asset) { @@ -1327,9 +1348,7 @@ function renderAssetList() { const name = document.createElement("strong"); name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; const details = document.createElement("small"); - details.textContent = isCodeAsset(asset) - ? "JavaScript" - : `${Math.round(asset.width)}x${Math.round(asset.height)}`; + details.textContent = getAssetTypeLabel(asset); meta.appendChild(name); meta.appendChild(details); @@ -1767,7 +1786,7 @@ function updateSelectedAssetSummary(asset) { } if (selectedAssetResolution) { if (asset) { - if (isCodeAsset(asset)) { + if (isCodeAsset(asset) || isAudioAsset(asset)) { selectedAssetResolution.textContent = ""; selectedAssetResolution.classList.add("hidden"); } else { @@ -2319,25 +2338,31 @@ function findAssetAtPoint(x, y) { function persistTransform(asset, silent = false) { cancelPendingTransform(asset.id); - const layer = getLayerValue(asset.id); + const payload = { + audioLoop: asset.audioLoop, + audioDelayMillis: asset.audioDelayMillis, + audioSpeed: asset.audioSpeed, + audioPitch: asset.audioPitch, + audioVolume: asset.audioVolume, + }; + if (!isAudioAsset(asset) && !isCodeAsset(asset)) { + const layer = getLayerValue(asset.id); + payload.x = asset.x; + payload.y = asset.y; + payload.width = asset.width; + payload.height = asset.height; + payload.rotation = asset.rotation; + payload.speed = asset.speed; + payload.layer = layer; + payload.zIndex = layer; + if (isVideoAsset(asset)) { + payload.muted = asset.muted; + } + } fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - x: asset.x, - y: asset.y, - width: asset.width, - height: asset.height, - rotation: asset.rotation, - speed: asset.speed, - layer, - zIndex: layer, - audioLoop: asset.audioLoop, - audioDelayMillis: asset.audioDelayMillis, - audioSpeed: asset.audioSpeed, - audioPitch: asset.audioPitch, - audioVolume: asset.audioVolume, - }), + body: JSON.stringify(payload), }) .then((r) => { if (!r.ok) { diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 5c887ce..ac2efd5 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -438,6 +438,9 @@ function recordDuration(assetId, seconds) { } function isVideoAsset(asset) { + if (asset?.assetType) { + return asset.assetType === "VIDEO"; + } return asset?.mediaType?.startsWith("video/"); } @@ -454,6 +457,9 @@ function getVideoPlaybackState(element) { } function isCodeAsset(asset) { + if (asset?.assetType) { + return asset.assetType === "SCRIPT"; + } const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); return type.startsWith("application/javascript") || type.startsWith("text/javascript"); } diff --git a/src/main/resources/static/js/media/audio.js b/src/main/resources/static/js/media/audio.js index 8d757ff..57f5231 100644 --- a/src/main/resources/static/js/media/audio.js +++ b/src/main/resources/static/js/media/audio.js @@ -2,6 +2,9 @@ export function isAudioAsset(asset) { if (!asset) { console.warn("isAudioAsset called with null or undefined asset"); } + if (asset?.assetType) { + return asset.assetType === "AUDIO"; + } const type = asset?.mediaType || asset?.originalMediaType || ""; return type.startsWith("audio/"); }