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) { public void run(ApplicationArguments args) {
ensureSessionAttributeUpsertTrigger(); ensureSessionAttributeUpsertTrigger();
ensureChannelCanvasColumns(); ensureChannelCanvasColumns();
ensureAssetMediaColumns(); ensureAssetTables();
ensureAuthorizedClientTable(); ensureAuthorizedClientTable();
normalizeAuthorizedClientTimestamps(); normalizeAuthorizedClientTimestamps();
} }
@@ -67,12 +67,12 @@ public class SchemaMigration implements ApplicationRunner {
addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080"); addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080");
} }
private void ensureAssetMediaColumns() { private void ensureAssetTables() {
List<String> columns; List<String> columns;
try { try {
columns = jdbcTemplate.query("PRAGMA table_info(assets)", (rs, rowNum) -> rs.getString("name")); columns = jdbcTemplate.query("PRAGMA table_info(assets)", (rs, rowNum) -> rs.getString("name"));
} catch (DataAccessException ex) { } 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; return;
} }
@@ -81,15 +81,121 @@ public class SchemaMigration implements ApplicationRunner {
} }
String table = "assets"; String table = "assets";
addColumnIfMissing(table, columns, "speed", "REAL", "1.0"); addColumnIfMissing(table, columns, "asset_type", "TEXT", "'OTHER'");
addColumnIfMissing(table, columns, "muted", "BOOLEAN", "0"); ensureAssetTypeTables(columns);
addColumnIfMissing(table, columns, "media_type", "TEXT", "'application/octet-stream'"); }
addColumnIfMissing(table, columns, "audio_loop", "BOOLEAN", "0");
addColumnIfMissing(table, columns, "audio_delay_millis", "INTEGER", "0"); private void ensureAssetTypeTables(List<String> assetColumns) {
addColumnIfMissing(table, columns, "audio_speed", "REAL", "1.0"); try {
addColumnIfMissing(table, columns, "audio_pitch", "REAL", "1.0"); jdbcTemplate.execute(
addColumnIfMissing(table, columns, "audio_volume", "REAL", "1.0"); """
addColumnIfMissing(table, columns, "preview", "TEXT", "NULL"); 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( private void addColumnIfMissing(

View File

@@ -2,6 +2,8 @@ package dev.kruhlmann.imgfloat.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
@@ -20,14 +22,9 @@ public class Asset {
@Column(nullable = false) @Column(nullable = false)
private String broadcaster; private String broadcaster;
@Column(nullable = false) @Enumerated(EnumType.STRING)
private String name; @Column(name = "asset_type", nullable = false)
private AssetType assetType;
@Column(columnDefinition = "TEXT", nullable = false)
private String url;
@Column(columnDefinition = "TEXT", nullable = false)
private String preview;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
@@ -35,39 +32,12 @@ public class Asset {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private Instant updatedAt; 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() {}
public Asset(String broadcaster, String name, String url, double width, double height) { public Asset(String broadcaster, AssetType assetType) {
this.id = UUID.randomUUID().toString(); this.id = UUID.randomUUID().toString();
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
this.name = name; this.assetType = assetType == null ? AssetType.OTHER : assetType;
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.createdAt = Instant.now(); this.createdAt = Instant.now();
this.updatedAt = this.createdAt; this.updatedAt = this.createdAt;
} }
@@ -84,32 +54,8 @@ public class Asset {
} }
this.updatedAt = now; this.updatedAt = now;
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
if (this.name == null || this.name.isBlank()) { if (this.assetType == null) {
this.name = this.id; this.assetType = AssetType.OTHER;
}
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;
} }
} }
@@ -125,112 +71,12 @@ public class Asset {
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
} }
public String getName() { public AssetType getAssetType() {
return name; return assetType == null ? AssetType.OTHER : assetType;
} }
public void setName(String name) { public void setAssetType(AssetType assetType) {
this.name = name; this.assetType = assetType == null ? AssetType.OTHER : assetType;
}
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 Instant getCreatedAt() { public Instant getCreatedAt() {
@@ -249,54 +95,6 @@ public class Asset {
this.updatedAt = updatedAt; 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) { private static String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }

View File

@@ -24,39 +24,43 @@ public record AssetPatch(
Double audioPitch, Double audioPitch,
Double audioVolume Double audioVolume
) { ) {
public static TransformSnapshot capture(Asset asset) { /**
return new TransformSnapshot( * Produces a minimal patch from a visual transform operation.
asset.getX(), */
asset.getY(), public static AssetPatch fromVisualTransform(VisualSnapshot before, VisualAsset asset, TransformRequest request) {
asset.getWidth(), return new AssetPatch(
asset.getHeight(), asset.getId(),
asset.getRotation(), request.getX() != null ? changed(before.x(), asset.getX()) : null,
asset.getSpeed(), request.getY() != null ? changed(before.y(), asset.getY()) : null,
asset.isMuted(), request.getWidth() != null ? changed(before.width(), asset.getWidth()) : null,
asset.getZIndex(), request.getHeight() != null ? changed(before.height(), asset.getHeight()) : null,
asset.isAudioLoop(), request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null,
asset.getAudioDelayMillis(), request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
asset.getAudioSpeed(), request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
asset.getAudioPitch(), request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
asset.getAudioVolume() 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 * Produces a minimal patch from an audio update operation.
* the incoming request are populated to keep WebSocket payloads small.
*/ */
public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) { public static AssetPatch fromAudioTransform(AudioSnapshot before, AudioAsset asset, TransformRequest request) {
return new AssetPatch( return new AssetPatch(
asset.getId(), asset.getId(),
changed(before.x(), asset.getX()), null,
changed(before.y(), asset.getY()), null,
changed(before.width(), asset.getWidth()), null,
changed(before.height(), asset.getHeight()), null,
changed(before.rotation(), asset.getRotation()), null,
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, null,
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, null,
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, null,
null, null,
request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null, request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null,
request.getAudioDelayMillis() != 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( return new AssetPatch(
asset.getId(), assetId,
null, null,
null, null,
null, null,
@@ -79,7 +83,7 @@ public record AssetPatch(
null, null,
null, null,
null, null,
asset.isHidden(), hidden,
null, null,
null, null,
null, null,
@@ -100,7 +104,7 @@ public record AssetPatch(
return before == after ? null : after; return before == after ? null : after;
} }
public record TransformSnapshot( public record VisualSnapshot(
double x, double x,
double y, double y,
double width, double width,
@@ -109,6 +113,10 @@ public record AssetPatch(
double speed, double speed,
boolean muted, boolean muted,
int zIndex, int zIndex,
double audioVolume
) {}
public record AudioSnapshot(
boolean audioLoop, boolean audioLoop,
int audioDelayMillis, int audioDelayMillis,
double audioSpeed, 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, Boolean muted,
String mediaType, String mediaType,
String originalMediaType, String originalMediaType,
AssetType assetType,
Integer zIndex, Integer zIndex,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
@@ -28,32 +29,92 @@ public record AssetView(
Instant createdAt, Instant createdAt,
Instant updatedAt 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( return new AssetView(
asset.getId(), asset.getId(),
asset.getBroadcaster(), asset.getBroadcaster(),
asset.getName(), visual.getName(),
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
asset.getPreview() != null && !asset.getPreview().isBlank() hasPreview ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null,
? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" visual.getX(),
: null, visual.getY(),
asset.getX(), visual.getWidth(),
asset.getY(), visual.getHeight(),
asset.getWidth(), visual.getRotation(),
asset.getHeight(), visual.getSpeed(),
asset.getRotation(), visual.isMuted(),
asset.getSpeed(), visual.getMediaType(),
asset.isMuted(), visual.getOriginalMediaType(),
asset.getMediaType(), asset.getAssetType(),
asset.getOriginalMediaType(), visual.getZIndex(),
asset.getZIndex(), null,
asset.isAudioLoop(), null,
asset.getAudioDelayMillis(), null,
asset.getAudioSpeed(), null,
asset.getAudioPitch(), visual.getAudioVolume(),
asset.getAudioVolume(), visual.isHidden(),
asset.isHidden(), hasPreview,
asset.getPreview() != null && !asset.getPreview().isBlank(), 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.getCreatedAt(),
asset.getUpdatedAt() 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 { public class TransformRequest {
private double x; private Double x;
private double y; private Double y;
@Positive(message = "Width must be greater than 0") @Positive(message = "Width must be greater than 0")
private double width; private Double width;
@Positive(message = "Height must be greater than 0") @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") @DecimalMin(value = "0.0", message = "Playback speed cannot be negative")
@DecimalMax(value = "4.0", message = "Playback speed cannot exceed 4.0") @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") @DecimalMax(value = "1.0", message = "Audio volume cannot exceed 1.0")
private Double audioVolume; private Double audioVolume;
public double getX() { public Double getX() {
return x; return x;
} }
public void setX(double x) { public void setX(Double x) {
this.x = x; this.x = x;
} }
public double getY() { public Double getY() {
return y; return y;
} }
public void setY(double y) { public void setY(Double y) {
this.y = y; this.y = y;
} }
public double getWidth() { public Double getWidth() {
return width; return width;
} }
public void setWidth(double width) { public void setWidth(Double width) {
this.width = width; this.width = width;
} }
public double getHeight() { public Double getHeight() {
return height; return height;
} }
public void setHeight(double height) { public void setHeight(Double height) {
this.height = height; this.height = height;
} }
public double getRotation() { public Double getRotation() {
return rotation; return rotation;
} }
public void setRotation(double rotation) { public void setRotation(Double rotation) {
this.rotation = 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> { public interface AssetRepository extends JpaRepository<Asset, String> {
List<Asset> findByBroadcaster(String broadcaster); 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; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.AssetContent;
import java.io.IOException; import java.io.IOException;
import java.nio.file.*; import java.nio.file.*;
@@ -97,53 +96,57 @@ public class AssetStorageService {
logger.info("Wrote asset to {}", file); logger.info("Wrote asset to {}", file);
} }
public Optional<AssetContent> loadAssetFile(Asset asset) { public Optional<AssetContent> loadAssetFile(String broadcaster, String assetId, String mediaType) {
try { try {
Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()); Path file = assetPath(broadcaster, assetId, mediaType);
if (!Files.exists(file)) return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
byte[] bytes = Files.readAllBytes(file); byte[] bytes = Files.readAllBytes(file);
return Optional.of(new AssetContent(bytes, asset.getMediaType())); return Optional.of(new AssetContent(bytes, mediaType));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to load asset {}", asset.getId(), e); logger.warn("Failed to load asset {}", assetId, e);
return Optional.empty(); return Optional.empty();
} }
} }
public Optional<AssetContent> loadPreview(Asset asset) { public Optional<AssetContent> loadPreview(String broadcaster, String assetId) {
try { try {
Path file = previewPath(asset.getBroadcaster(), asset.getId()); Path file = previewPath(broadcaster, assetId);
if (!Files.exists(file)) return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
byte[] bytes = Files.readAllBytes(file); byte[] bytes = Files.readAllBytes(file);
return Optional.of(new AssetContent(bytes, "image/png")); return Optional.of(new AssetContent(bytes, "image/png"));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to load preview {}", asset.getId(), e); logger.warn("Failed to load preview {}", assetId, e);
return Optional.empty(); return Optional.empty();
} }
} }
public Optional<AssetContent> loadAssetFileSafely(Asset asset) { public Optional<AssetContent> loadAssetFileSafely(String broadcaster, String assetId, String mediaType) {
if (asset.getUrl() == null) { if (mediaType == null) {
return Optional.empty(); return Optional.empty();
} }
return loadAssetFile(asset); return loadAssetFile(broadcaster, assetId, mediaType);
} }
public Optional<AssetContent> loadPreviewSafely(Asset asset) { public Optional<AssetContent> loadPreviewSafely(String broadcaster, String assetId, boolean hasPreview) {
if (asset.getPreview() == null) { if (!hasPreview) {
return Optional.empty(); 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 { try {
Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())); if (mediaType != null) {
Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId())); Files.deleteIfExists(assetPath(broadcaster, assetId, mediaType));
}
if (hasPreview) {
Files.deleteIfExists(previewPath(broadcaster, assetId));
}
} catch (Exception e) { } 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.Asset;
import dev.kruhlmann.imgfloat.model.AssetEvent; import dev.kruhlmann.imgfloat.model.AssetEvent;
import dev.kruhlmann.imgfloat.model.AssetPatch; import dev.kruhlmann.imgfloat.model.AssetPatch;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.AudioAsset;
import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.CanvasEvent;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.VisualAsset;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; 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.AssetContent;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
@@ -25,6 +32,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -42,6 +50,9 @@ public class ChannelDirectoryService {
private final ChannelRepository channelRepository; private final ChannelRepository channelRepository;
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
private final VisualAssetRepository visualAssetRepository;
private final AudioAssetRepository audioAssetRepository;
private final ScriptAssetRepository scriptAssetRepository;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
private final MediaDetectionService mediaDetectionService; private final MediaDetectionService mediaDetectionService;
@@ -53,6 +64,9 @@ public class ChannelDirectoryService {
public ChannelDirectoryService( public ChannelDirectoryService(
ChannelRepository channelRepository, ChannelRepository channelRepository,
AssetRepository assetRepository, AssetRepository assetRepository,
VisualAssetRepository visualAssetRepository,
AudioAssetRepository audioAssetRepository,
ScriptAssetRepository scriptAssetRepository,
SimpMessagingTemplate messagingTemplate, SimpMessagingTemplate messagingTemplate,
AssetStorageService assetStorageService, AssetStorageService assetStorageService,
MediaDetectionService mediaDetectionService, MediaDetectionService mediaDetectionService,
@@ -62,6 +76,9 @@ public class ChannelDirectoryService {
) { ) {
this.channelRepository = channelRepository; this.channelRepository = channelRepository;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.visualAssetRepository = visualAssetRepository;
this.audioAssetRepository = audioAssetRepository;
this.scriptAssetRepository = scriptAssetRepository;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
this.mediaDetectionService = mediaDetectionService; this.mediaDetectionService = mediaDetectionService;
@@ -111,7 +128,28 @@ public class ChannelDirectoryService {
public Collection<AssetView> getVisibleAssets(String broadcaster) { public Collection<AssetView> getVisibleAssets(String broadcaster) {
String normalized = normalize(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) { public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
@@ -159,14 +197,8 @@ public class ChannelDirectoryService {
boolean isAudio = optimized.mediaType().startsWith("audio/"); boolean isAudio = optimized.mediaType().startsWith("audio/");
boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType); boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType);
double defaultWidth = isAudio ? 400 : isCode ? 480 : 640; AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
double defaultHeight = isAudio ? 80 : isCode ? 270 : 360; Asset asset = new Asset(channel.getBroadcaster(), assetType);
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());
assetStorageService.storeAsset( assetStorageService.storeAsset(
channel.getBroadcaster(), channel.getBroadcaster(),
@@ -175,21 +207,37 @@ public class ChannelDirectoryService {
optimized.mediaType() optimized.mediaType()
); );
AssetView view;
asset = assetRepository.save(asset);
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()); assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
visualAssetRepository.save(visual);
view = AssetView.fromVisual(channel.getBroadcaster(), asset, visual);
}
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()));
assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
return Optional.of(view); return Optional.of(view);
@@ -201,18 +249,7 @@ public class ChannelDirectoryService {
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
enforceUploadLimit(bytes.length); enforceUploadLimit(bytes.length);
Asset asset = new Asset(channel.getBroadcaster(), request.getName().trim(), "", 480, 270); Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
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()));
try { try {
assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); 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); throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e);
} }
assetRepository.save(asset); asset = assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), 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)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
return Optional.of(view); return Optional.of(view);
} }
@@ -236,19 +277,23 @@ public class ChannelDirectoryService {
.findById(assetId) .findById(assetId)
.filter((asset) -> normalized.equals(asset.getBroadcaster())) .filter((asset) -> normalized.equals(asset.getBroadcaster()))
.map((asset) -> { .map((asset) -> {
if (!isCodeMediaType(asset.getMediaType()) && !isCodeMediaType(asset.getOriginalMediaType())) { if (asset.getAssetType() != AssetType.SCRIPT) {
throw new ResponseStatusException(BAD_REQUEST, "Asset is not a script"); throw new ResponseStatusException(BAD_REQUEST, "Asset is not a script");
} }
asset.setName(request.getName().trim()); ScriptAsset script = scriptAssetRepository
asset.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); .findById(asset.getId())
asset.setMediaType(DEFAULT_CODE_MEDIA_TYPE); .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 { try {
assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE);
} catch (IOException e) { } catch (IOException e) {
throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e);
} }
assetRepository.save(asset); 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)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
return view; return view;
}); });
@@ -266,28 +311,69 @@ public class ChannelDirectoryService {
.findById(assetId) .findById(assetId)
.filter((asset) -> normalized.equals(asset.getBroadcaster())) .filter((asset) -> normalized.equals(asset.getBroadcaster()))
.map((asset) -> { .map((asset) -> {
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset); if (asset.getAssetType() == AssetType.AUDIO) {
validateTransform(req); 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()); if (asset.getAssetType() == AssetType.SCRIPT) {
asset.setY(req.getY()); ScriptAsset script = scriptAssetRepository
asset.setWidth(req.getWidth()); .findById(asset.getId())
asset.setHeight(req.getHeight()); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
asset.setRotation(req.getRotation()); return AssetView.fromScript(normalized, asset, script);
}
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); VisualAsset visual = visualAssetRepository
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); .findById(asset.getId())
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual"));
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); AssetPatch.VisualSnapshot before = new AssetPatch.VisualSnapshot(
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); visual.getX(),
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); visual.getY(),
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); visual.getWidth(),
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); 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); visualAssetRepository.save(visual);
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
AssetView view = AssetView.fromVisual(normalized, asset, visual);
AssetPatch patch = AssetPatch.fromVisualTransform(before, visual, req);
if (hasPatchChanges(patch)) { if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, 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(); Settings settings = settingsService.get();
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
double minSpeed = settings.getMinAssetPlaybackSpeedFraction(); double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
double minPitch = settings.getMinAssetAudioPitchFraction();
double maxPitch = settings.getMaxAssetAudioPitchFraction();
double minVolume = settings.getMinAssetVolumeFraction(); double minVolume = settings.getMinAssetVolumeFraction();
double maxVolume = settings.getMaxAssetVolumeFraction(); double maxVolume = settings.getMaxAssetVolumeFraction();
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); 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, BAD_REQUEST,
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]" "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, BAD_REQUEST,
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]" "Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
); );
@@ -320,6 +406,23 @@ public class ChannelDirectoryService {
BAD_REQUEST, BAD_REQUEST,
"zIndex must be >= 1" "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( if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) throw new ResponseStatusException(
BAD_REQUEST, BAD_REQUEST,
"Audio delay >= 0" "Audio delay >= 0"
@@ -344,7 +447,10 @@ public class ChannelDirectoryService {
.findById(assetId) .findById(assetId)
.filter((a) -> normalized.equals(a.getBroadcaster())) .filter((a) -> normalized.equals(a.getBroadcaster()))
.map((asset) -> { .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(); boolean play = req == null || req.getPlay();
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play));
return view; return view;
@@ -357,16 +463,43 @@ public class ChannelDirectoryService {
.findById(assetId) .findById(assetId)
.filter((a) -> normalized.equals(a.getBroadcaster())) .filter((a) -> normalized.equals(a.getBroadcaster()))
.map((asset) -> { .map((asset) -> {
boolean wasHidden = asset.isHidden();
boolean hidden = request.isHidden(); boolean hidden = request.isHidden();
if (wasHidden == hidden) { if (asset.getAssetType() == AssetType.AUDIO) {
return AssetView.from(normalized, asset); 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); if (asset.getAssetType() == AssetType.SCRIPT) {
assetRepository.save(asset); ScriptAsset script = scriptAssetRepository
AssetView view = AssetView.from(normalized, asset); .findById(asset.getId())
AssetPatch patch = AssetPatch.fromVisibility(asset); .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; AssetView payload = hidden ? null : view;
messagingTemplate.convertAndSend( messagingTemplate.convertAndSend(
topicFor(broadcaster), topicFor(broadcaster),
@@ -380,8 +513,13 @@ public class ChannelDirectoryService {
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
.map((asset) -> { .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); assetRepository.delete(asset);
assetStorageService.deleteAsset(asset);
messagingTemplate.convertAndSend( messagingTemplate.convertAndSend(
topicFor(asset.getBroadcaster()), topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId) AssetEvent.deleted(asset.getBroadcaster(), assetId)
@@ -392,14 +530,11 @@ public class ChannelDirectoryService {
} }
public Optional<AssetContent> getAssetContent(String assetId) { 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) { public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
return assetRepository return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden));
.findById(assetId)
.filter((a) -> includeHidden || !a.isHidden())
.flatMap(assetStorageService::loadPreviewSafely);
} }
public boolean isAdmin(String broadcaster, String username) { public boolean isAdmin(String broadcaster, String username) {
@@ -457,30 +592,187 @@ public class ChannelDirectoryService {
} }
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) { 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 return assets
.stream() .stream()
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts))
.filter(Objects::nonNull)
.sorted( .sorted(
Comparator.comparingInt(Asset::getZIndex).thenComparing( Comparator.comparing((AssetView view) ->
Asset::getCreatedAt, view.zIndex() == null ? Integer.MAX_VALUE : view.zIndex()
Comparator.nullsFirst(Comparator.naturalOrder()) ).thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
) )
)
.map((a) -> AssetView.from(broadcaster, a))
.toList(); .toList();
} }
private int nextZIndex(String broadcaster) { private int nextZIndex(String broadcaster) {
return ( return (
assetRepository visualAssetRepository
.findByBroadcaster(normalize(broadcaster)) .findByIdIn(assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.OTHER))
.stream() .stream()
.mapToInt(Asset::getZIndex) .mapToInt(VisualAsset::getZIndex)
.max() .max()
.orElse(0) + .orElse(0) +
1 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) { private boolean hasPatchChanges(AssetPatch patch) {
return ( return (
patch.x() != null || patch.x() != null ||

View File

@@ -947,11 +947,17 @@ function updateHoverCursor(point) {
} }
function isVideoAsset(asset) { function isVideoAsset(asset) {
if (asset?.assetType) {
return asset.assetType === "VIDEO";
}
const type = asset?.mediaType || asset?.originalMediaType || ""; const type = asset?.mediaType || asset?.originalMediaType || "";
return type.startsWith("video/"); return type.startsWith("video/");
} }
function isCodeAsset(asset) { function isCodeAsset(asset) {
if (asset?.assetType) {
return asset.assetType === "SCRIPT";
}
const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase();
return type.startsWith("application/javascript") || type.startsWith("text/javascript"); return type.startsWith("application/javascript") || type.startsWith("text/javascript");
} }
@@ -972,16 +978,31 @@ function isVideoElement(element) {
return element && element.tagName === "VIDEO"; return element && element.tagName === "VIDEO";
} }
function getDisplayMediaType(asset) { function getAssetTypeLabel(asset) {
const raw = asset.originalMediaType || asset.mediaType || ""; const type = asset?.assetType;
if (!raw) { if (type) {
return "Unknown"; const lookup = {
IMAGE: "Image",
VIDEO: "Video",
AUDIO: "Audio",
SCRIPT: "Script",
OTHER: "Other",
};
return lookup[type] || "Other";
} }
if (isCodeAsset(asset)) { if (isCodeAsset(asset)) {
return "JavaScript"; return "Script";
}
const raw = asset?.originalMediaType || asset?.mediaType || "";
if (!raw) {
return "Other";
} }
const parts = raw.split("/"); 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) { function isGifAsset(asset) {
@@ -1327,9 +1348,7 @@ function renderAssetList() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
const details = document.createElement("small"); const details = document.createElement("small");
details.textContent = isCodeAsset(asset) details.textContent = getAssetTypeLabel(asset);
? "JavaScript"
: `${Math.round(asset.width)}x${Math.round(asset.height)}`;
meta.appendChild(name); meta.appendChild(name);
meta.appendChild(details); meta.appendChild(details);
@@ -1767,7 +1786,7 @@ function updateSelectedAssetSummary(asset) {
} }
if (selectedAssetResolution) { if (selectedAssetResolution) {
if (asset) { if (asset) {
if (isCodeAsset(asset)) { if (isCodeAsset(asset) || isAudioAsset(asset)) {
selectedAssetResolution.textContent = ""; selectedAssetResolution.textContent = "";
selectedAssetResolution.classList.add("hidden"); selectedAssetResolution.classList.add("hidden");
} else { } else {
@@ -2319,25 +2338,31 @@ function findAssetAtPoint(x, y) {
function persistTransform(asset, silent = false) { function persistTransform(asset, silent = false) {
cancelPendingTransform(asset.id); cancelPendingTransform(asset.id);
const layer = getLayerValue(asset.id); const payload = {
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, audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis, audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed, audioSpeed: asset.audioSpeed,
audioPitch: asset.audioPitch, audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume, 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(payload),
}) })
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {

View File

@@ -438,6 +438,9 @@ function recordDuration(assetId, seconds) {
} }
function isVideoAsset(asset) { function isVideoAsset(asset) {
if (asset?.assetType) {
return asset.assetType === "VIDEO";
}
return asset?.mediaType?.startsWith("video/"); return asset?.mediaType?.startsWith("video/");
} }
@@ -454,6 +457,9 @@ function getVideoPlaybackState(element) {
} }
function isCodeAsset(asset) { function isCodeAsset(asset) {
if (asset?.assetType) {
return asset.assetType === "SCRIPT";
}
const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase();
return type.startsWith("application/javascript") || type.startsWith("text/javascript"); return type.startsWith("application/javascript") || type.startsWith("text/javascript");
} }

View File

@@ -2,6 +2,9 @@ export function isAudioAsset(asset) {
if (!asset) { if (!asset) {
console.warn("isAudioAsset called with null or undefined asset"); console.warn("isAudioAsset called with null or undefined asset");
} }
if (asset?.assetType) {
return asset.assetType === "AUDIO";
}
const type = asset?.mediaType || asset?.originalMediaType || ""; const type = asset?.mediaType || asset?.originalMediaType || "";
return type.startsWith("audio/"); return type.startsWith("audio/");
} }