Explode assets class into subtypes

This commit is contained in:
2026-01-08 20:10:50 +01:00
parent afd9ee2cc5
commit 81c078d95f
18 changed files with 1193 additions and 425 deletions

View File

@@ -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<String> 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<String> 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<String> 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(

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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()
);

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -6,5 +6,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface AssetRepository extends JpaRepository<Asset, String> {
List<Asset> findByBroadcaster(String broadcaster);
List<Asset> findByBroadcasterAndHiddenFalse(String broadcaster);
}

View File

@@ -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<AudioAsset, String> {
List<AudioAsset> findByIdIn(Collection<String> ids);
}

View File

@@ -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<ScriptAsset, String> {
List<ScriptAsset> findByIdIn(Collection<String> ids);
}

View File

@@ -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<VisualAsset, String> {
List<VisualAsset> findByIdIn(Collection<String> ids);
List<VisualAsset> findByIdInAndHiddenFalse(Collection<String> ids);
}

View File

@@ -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<AssetContent> loadAssetFile(Asset asset) {
public Optional<AssetContent> 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<AssetContent> loadPreview(Asset asset) {
public Optional<AssetContent> 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<AssetContent> loadAssetFileSafely(Asset asset) {
if (asset.getUrl() == null) {
public Optional<AssetContent> loadAssetFileSafely(String broadcaster, String assetId, String mediaType) {
if (mediaType == null) {
return Optional.empty();
}
return loadAssetFile(asset);
return loadAssetFile(broadcaster, assetId, mediaType);
}
public Optional<AssetContent> loadPreviewSafely(Asset asset) {
if (asset.getPreview() == null) {
public Optional<AssetContent> 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);
}
}

View File

@@ -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<AssetView> getVisibleAssets(String broadcaster) {
String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized));
List<Asset> assets = assetRepository.findByBroadcaster(normalized);
List<String> visualIds = assets
.stream()
.filter(
(asset) ->
asset.getAssetType() == AssetType.IMAGE ||
asset.getAssetType() == AssetType.VIDEO ||
asset.getAssetType() == AssetType.OTHER
)
.map(Asset::getId)
.toList();
Map<String, Asset> 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<AssetContent> getAssetContent(String assetId) {
return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely);
return assetRepository.findById(assetId).flatMap(this::loadAssetContent);
}
public Optional<AssetContent> 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<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
List<String> audioIds = assets
.stream()
.filter((asset) -> asset.getAssetType() == AssetType.AUDIO)
.map(Asset::getId)
.toList();
List<String> scriptIds = assets
.stream()
.filter((asset) -> asset.getAssetType() == AssetType.SCRIPT)
.map(Asset::getId)
.toList();
List<String> visualIds = assets
.stream()
.filter(
(asset) ->
asset.getAssetType() == AssetType.IMAGE ||
asset.getAssetType() == AssetType.VIDEO ||
asset.getAssetType() == AssetType.OTHER
)
.map(Asset::getId)
.toList();
Map<String, VisualAsset> visuals = visualAssetRepository
.findByIdIn(visualIds)
.stream()
.collect(Collectors.toMap(VisualAsset::getId, (asset) -> asset));
Map<String, AudioAsset> audios = audioAssetRepository
.findByIdIn(audioIds)
.stream()
.collect(Collectors.toMap(AudioAsset::getId, (asset) -> asset));
Map<String, ScriptAsset> 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<String> assetsWithType(String broadcaster, AssetType... types) {
Set<AssetType> 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<String, VisualAsset> visuals,
Map<String, AudioAsset> audios,
Map<String, ScriptAsset> 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<AssetContent> 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<AssetContent> 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 ||