Add support for 3d models in assets and attachments

This commit is contained in:
2026-01-13 13:46:28 +01:00
parent e3d3a62f84
commit f215ef9aba
20 changed files with 15057 additions and 24 deletions

View File

@@ -232,6 +232,7 @@ public class SchemaMigration implements ApplicationRunner {
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 'model/%' THEN 'MODEL'
WHEN media_type LIKE 'application/javascript%' THEN 'SCRIPT'
WHEN media_type LIKE 'text/javascript%' THEN 'SCRIPT'
ELSE COALESCE(asset_type, 'OTHER')
@@ -248,7 +249,7 @@ public class SchemaMigration implements ApplicationRunner {
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')
WHERE asset_type IN ('IMAGE', 'VIDEO', 'MODEL', 'OTHER')
"""
);
jdbcTemplate.execute(

View File

@@ -6,6 +6,7 @@ public enum AssetType {
IMAGE,
VIDEO,
AUDIO,
MODEL,
SCRIPT,
OTHER;
@@ -24,6 +25,9 @@ public enum AssetType {
if (normalized.startsWith("audio/")) {
return AUDIO;
}
if (normalized.startsWith("model/")) {
return MODEL;
}
if (normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript")) {
return SCRIPT;
}

View File

@@ -34,6 +34,9 @@ public class AssetStorageService {
Map.entry("audio/ogg", ".ogg"),
Map.entry("audio/webm", ".webm"),
Map.entry("audio/flac", ".flac"),
Map.entry("model/gltf-binary", ".glb"),
Map.entry("model/gltf+json", ".gltf"),
Map.entry("model/obj", ".obj"),
Map.entry("application/javascript", ".js"),
Map.entry("text/javascript", ".js")
);

View File

@@ -155,6 +155,7 @@ public class ChannelDirectoryService {
(asset) ->
asset.getAssetType() == AssetType.IMAGE ||
asset.getAssetType() == AssetType.VIDEO ||
asset.getAssetType() == AssetType.MODEL ||
asset.getAssetType() == AssetType.OTHER
)
.map(Asset::getId)
@@ -635,7 +636,7 @@ public class ChannelDirectoryService {
scriptAttachment.setFileId(attachmentFileId);
scriptAttachment.setMediaType(attachmentContent.mediaType());
scriptAttachment.setOriginalMediaType(attachmentContent.mediaType());
scriptAttachment.setAssetType(AssetType.IMAGE);
scriptAttachment.setAssetType(AssetType.fromMediaType(attachmentContent.mediaType(), attachmentContent.mediaType()));
attachments.add(scriptAttachment);
}
if (!attachments.isEmpty()) {
@@ -674,7 +675,8 @@ public class ChannelDirectoryService {
}
private String storeScriptAttachmentFile(Asset asset, AssetContent attachmentContent) {
ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE);
AssetType assetType = AssetType.fromMediaType(attachmentContent.mediaType(), attachmentContent.mediaType());
ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), assetType);
attachmentFile.setMediaType(attachmentContent.mediaType());
attachmentFile.setOriginalMediaType(attachmentContent.mediaType());
try {
@@ -981,8 +983,16 @@ public class ChannelDirectoryService {
}
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
if (assetType != AssetType.AUDIO && assetType != AssetType.IMAGE && assetType != AssetType.VIDEO) {
throw new ResponseStatusException(BAD_REQUEST, "Only image, video, or audio attachments are supported.");
if (
assetType != AssetType.AUDIO &&
assetType != AssetType.IMAGE &&
assetType != AssetType.VIDEO &&
assetType != AssetType.MODEL
) {
throw new ResponseStatusException(
BAD_REQUEST,
"Only image, video, audio, or 3D model attachments are supported."
);
}
String safeName = Optional.ofNullable(file.getOriginalFilename())
@@ -1173,6 +1183,7 @@ public class ChannelDirectoryService {
(asset) ->
asset.getAssetType() == AssetType.IMAGE ||
asset.getAssetType() == AssetType.VIDEO ||
asset.getAssetType() == AssetType.MODEL ||
asset.getAssetType() == AssetType.OTHER
)
.map(Asset::getId)
@@ -1220,7 +1231,9 @@ public class ChannelDirectoryService {
private int nextZIndex(String broadcaster) {
return (
visualAssetRepository
.findByIdIn(assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.OTHER))
.findByIdIn(
assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
)
.stream()
.mapToInt(VisualAsset::getZIndex)
.max()
@@ -1374,6 +1387,7 @@ public class ChannelDirectoryService {
if (
asset.getAssetType() != AssetType.VIDEO &&
asset.getAssetType() != AssetType.IMAGE &&
asset.getAssetType() != AssetType.MODEL &&
asset.getAssetType() != AssetType.OTHER
) {
return Optional.empty();

View File

@@ -28,6 +28,22 @@ public class MarketplaceScriptSeedLoader {
private static final String ATTACHMENTS_DIR = "attachments";
private static final String DEFAULT_SOURCE_MEDIA_TYPE = "application/javascript";
private static final String DEFAULT_LOGO_MEDIA_TYPE = "image/png";
private static final java.util.Map<String, String> ATTACHMENT_MEDIA_TYPES = java.util.Map.ofEntries(
java.util.Map.entry("png", "image/png"),
java.util.Map.entry("jpg", "image/jpeg"),
java.util.Map.entry("jpeg", "image/jpeg"),
java.util.Map.entry("gif", "image/gif"),
java.util.Map.entry("webp", "image/webp"),
java.util.Map.entry("mp4", "video/mp4"),
java.util.Map.entry("webm", "video/webm"),
java.util.Map.entry("mov", "video/quicktime"),
java.util.Map.entry("mp3", "audio/mpeg"),
java.util.Map.entry("wav", "audio/wav"),
java.util.Map.entry("ogg", "audio/ogg"),
java.util.Map.entry("glb", "model/gltf-binary"),
java.util.Map.entry("gltf", "model/gltf+json"),
java.util.Map.entry("obj", "model/obj")
);
private final List<SeedScript> scripts;
@@ -175,7 +191,7 @@ public class MarketplaceScriptSeedLoader {
logger.warn("Duplicate marketplace attachment name {}", name);
return Optional.empty();
}
String mediaType = Files.probeContentType(attachment);
String mediaType = detectAttachmentMediaType(attachment);
attachments.add(
new SeedAttachment(
name,
@@ -203,6 +219,32 @@ public class MarketplaceScriptSeedLoader {
return null;
}
private String detectAttachmentMediaType(Path attachment) {
try {
String mediaType = Files.probeContentType(attachment);
if (
mediaType != null &&
!mediaType.isBlank() &&
!"application/octet-stream".equals(mediaType) &&
!"text/plain".equals(mediaType)
) {
return mediaType;
}
} catch (IOException ex) {
logger.warn("Failed to detect media type for {}", attachment, ex);
}
String filename = attachment.getFileName().toString().toLowerCase(Locale.ROOT);
int dot = filename.lastIndexOf('.');
if (dot > -1 && dot < filename.length() - 1) {
String extension = filename.substring(dot + 1);
String mapped = ATTACHMENT_MEDIA_TYPES.get(extension);
if (mapped != null) {
return mapped;
}
}
return "application/octet-stream";
}
private String normalizeBroadcaster(String broadcaster) {
if (broadcaster == null || broadcaster.isBlank()) {
return "System";

View File

@@ -28,6 +28,9 @@ public class MediaDetectionService {
Map.entry("mp3", "audio/mpeg"),
Map.entry("wav", "audio/wav"),
Map.entry("ogg", "audio/ogg"),
Map.entry("glb", "model/gltf-binary"),
Map.entry("gltf", "model/gltf+json"),
Map.entry("obj", "model/obj"),
Map.entry("js", "application/javascript"),
Map.entry("mjs", "text/javascript")
);

View File

@@ -73,6 +73,10 @@ public class MediaOptimizationService {
return new OptimizedAsset(bytes, mediaType, 0, 0, null);
}
if (mediaType.startsWith("model/")) {
return new OptimizedAsset(bytes, mediaType, 0, 0, null);
}
if (mediaType.startsWith("application/javascript") || mediaType.startsWith("text/javascript")) {
return new OptimizedAsset(bytes, mediaType, 0, 0, null);
}