mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add default custom asset
This commit is contained in:
@@ -41,9 +41,11 @@ import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -424,7 +426,20 @@ public class ChannelDirectoryService {
|
||||
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
|
||||
String q = normalizeDescription(query);
|
||||
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
|
||||
List<ScriptAsset> scripts = scriptAssetRepository.findByIsPublicTrue();
|
||||
List<ScriptMarketplaceEntry> entries = new ArrayList<>();
|
||||
DefaultMarketplaceScript.entryForQuery(normalizedQuery).ifPresent(entries::add);
|
||||
List<ScriptAsset> scripts;
|
||||
try {
|
||||
scripts = scriptAssetRepository.findByIsPublicTrue();
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to load marketplace scripts", ex);
|
||||
return entries
|
||||
.stream()
|
||||
.sorted(
|
||||
Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase))
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
if (normalizedQuery != null && !normalizedQuery.isBlank()) {
|
||||
scripts =
|
||||
scripts
|
||||
@@ -442,7 +457,8 @@ public class ChannelDirectoryService {
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Asset::getId, (asset) -> asset));
|
||||
|
||||
return scripts
|
||||
entries.addAll(
|
||||
scripts
|
||||
.stream()
|
||||
.map((script) -> {
|
||||
Asset asset = assets.get(script.getId());
|
||||
@@ -458,11 +474,20 @@ public class ChannelDirectoryService {
|
||||
broadcaster
|
||||
);
|
||||
})
|
||||
.toList()
|
||||
);
|
||||
|
||||
return entries
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getMarketplaceLogo(String scriptId) {
|
||||
if (DefaultMarketplaceScript.matches(scriptId)) {
|
||||
return DefaultMarketplaceScript.logoContent();
|
||||
}
|
||||
try {
|
||||
return scriptAssetRepository
|
||||
.findById(scriptId)
|
||||
.filter(ScriptAsset::isPublic)
|
||||
@@ -471,13 +496,23 @@ public class ChannelDirectoryService {
|
||||
.flatMap((file) ->
|
||||
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
|
||||
);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to load marketplace logo", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) {
|
||||
ScriptAsset sourceScript = scriptAssetRepository
|
||||
.findById(scriptId)
|
||||
.filter(ScriptAsset::isPublic)
|
||||
.orElse(null);
|
||||
if (DefaultMarketplaceScript.matches(scriptId)) {
|
||||
return importDefaultMarketplaceScript(targetBroadcaster);
|
||||
}
|
||||
ScriptAsset sourceScript;
|
||||
try {
|
||||
sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to import marketplace script {}", scriptId, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null);
|
||||
if (sourceScript == null || sourceAsset == null) {
|
||||
return Optional.empty();
|
||||
@@ -538,6 +573,69 @@ public class ChannelDirectoryService {
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
private Optional<AssetView> importDefaultMarketplaceScript(String targetBroadcaster) {
|
||||
AssetContent sourceContent = DefaultMarketplaceScript.sourceContent().orElse(null);
|
||||
AssetContent attachmentContent = DefaultMarketplaceScript.attachmentContent().orElse(null);
|
||||
if (sourceContent == null || attachmentContent == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
sourceFile.setId(asset.getId());
|
||||
sourceFile.setMediaType(sourceContent.mediaType());
|
||||
sourceFile.setOriginalMediaType(sourceContent.mediaType());
|
||||
try {
|
||||
assetStorageService.storeAsset(
|
||||
sourceFile.getBroadcaster(),
|
||||
sourceFile.getId(),
|
||||
sourceContent.bytes(),
|
||||
sourceContent.mediaType()
|
||||
);
|
||||
} catch (IOException e) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e);
|
||||
}
|
||||
assetRepository.save(asset);
|
||||
scriptAssetFileRepository.save(sourceFile);
|
||||
|
||||
ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE);
|
||||
attachmentFile.setMediaType(attachmentContent.mediaType());
|
||||
attachmentFile.setOriginalMediaType(attachmentContent.mediaType());
|
||||
try {
|
||||
assetStorageService.storeAsset(
|
||||
attachmentFile.getBroadcaster(),
|
||||
attachmentFile.getId(),
|
||||
attachmentContent.bytes(),
|
||||
attachmentContent.mediaType()
|
||||
);
|
||||
} catch (IOException e) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Unable to store script attachment", e);
|
||||
}
|
||||
scriptAssetFileRepository.save(attachmentFile);
|
||||
|
||||
ScriptAsset script = new ScriptAsset(asset.getId(), DefaultMarketplaceScript.SCRIPT_NAME);
|
||||
script.setDescription(DefaultMarketplaceScript.SCRIPT_DESCRIPTION);
|
||||
script.setPublic(false);
|
||||
script.setMediaType(sourceContent.mediaType());
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setLogoFileId(attachmentFile.getId());
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), DefaultMarketplaceScript.ATTACHMENT_NAME);
|
||||
attachment.setFileId(attachmentFile.getId());
|
||||
attachment.setMediaType(attachmentContent.mediaType());
|
||||
attachment.setOriginalMediaType(attachmentContent.mediaType());
|
||||
attachment.setAssetType(AssetType.IMAGE);
|
||||
scriptAssetAttachmentRepository.save(attachment);
|
||||
|
||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view));
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String original) {
|
||||
String stripped = original.replaceAll("^.*[/\\\\]", "");
|
||||
return SAFE_FILENAME.matcher(stripped).replaceAll("_");
|
||||
@@ -750,6 +848,7 @@ public class ChannelDirectoryService {
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteAsset(String assetId) {
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
public final class DefaultMarketplaceScript {
|
||||
|
||||
public static final String SCRIPT_ID = "imgfloat-default-rotating-logo";
|
||||
public static final String SCRIPT_NAME = "Rotating Imgfloat logo";
|
||||
public static final String SCRIPT_DESCRIPTION =
|
||||
"Renders the Imgfloat logo and rotates it every tick.";
|
||||
public static final String SCRIPT_BROADCASTER = "Imgfloat";
|
||||
public static final String ATTACHMENT_NAME = "Imgfloat logo";
|
||||
public static final String LOGO_URL = "/api/marketplace/scripts/" + SCRIPT_ID + "/logo";
|
||||
public static final String SOURCE_MEDIA_TYPE = "application/javascript";
|
||||
public static final String LOGO_MEDIA_TYPE = "image/png";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DefaultMarketplaceScript.class);
|
||||
private static final String LOGO_RESOURCE = "static/img/brand.png";
|
||||
private static final String SOURCE_RESOURCE = "assets/default-marketplace-script.js";
|
||||
private static final AtomicReference<byte[]> LOGO_BYTES = new AtomicReference<>();
|
||||
private static final AtomicReference<byte[]> SOURCE_BYTES = new AtomicReference<>();
|
||||
|
||||
private DefaultMarketplaceScript() {}
|
||||
|
||||
public static boolean matches(String scriptId) {
|
||||
return SCRIPT_ID.equals(scriptId);
|
||||
}
|
||||
|
||||
public static Optional<ScriptMarketplaceEntry> entryForQuery(String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return Optional.of(entry());
|
||||
}
|
||||
String normalized = query.toLowerCase(Locale.ROOT);
|
||||
if (
|
||||
SCRIPT_NAME.toLowerCase(Locale.ROOT).contains(normalized) ||
|
||||
SCRIPT_DESCRIPTION.toLowerCase(Locale.ROOT).contains(normalized)
|
||||
) {
|
||||
return Optional.of(entry());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static ScriptMarketplaceEntry entry() {
|
||||
return new ScriptMarketplaceEntry(
|
||||
SCRIPT_ID,
|
||||
SCRIPT_NAME,
|
||||
SCRIPT_DESCRIPTION,
|
||||
LOGO_URL,
|
||||
SCRIPT_BROADCASTER
|
||||
);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> logoContent() {
|
||||
return loadContent(LOGO_BYTES, LOGO_RESOURCE, LOGO_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> sourceContent() {
|
||||
return loadContent(SOURCE_BYTES, SOURCE_RESOURCE, SOURCE_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> attachmentContent() {
|
||||
return logoContent();
|
||||
}
|
||||
|
||||
private static Optional<AssetContent> loadContent(
|
||||
AtomicReference<byte[]> cache,
|
||||
String resourcePath,
|
||||
String mediaType
|
||||
) {
|
||||
byte[] bytes = cache.get();
|
||||
if (bytes == null) {
|
||||
bytes = readBytes(resourcePath).orElse(null);
|
||||
if (bytes != null) {
|
||||
cache.set(bytes);
|
||||
}
|
||||
}
|
||||
if (bytes == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AssetContent(bytes, mediaType));
|
||||
}
|
||||
|
||||
private static Optional<byte[]> readBytes(String resourcePath) {
|
||||
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||
if (!resource.exists()) {
|
||||
logger.warn("Default marketplace resource {} is missing", resourcePath);
|
||||
return Optional.empty();
|
||||
}
|
||||
try (InputStream input = resource.getInputStream()) {
|
||||
return Optional.of(input.readAllBytes());
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read default marketplace resource {}", resourcePath, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/main/resources/assets/default-marketplace-script.js
Normal file
33
src/main/resources/assets/default-marketplace-script.js
Normal file
@@ -0,0 +1,33 @@
|
||||
async function init(context, state) {
|
||||
const asset = Array.isArray(context.assets) ? context.assets[0] : null;
|
||||
if (!asset?.blob) {
|
||||
return;
|
||||
}
|
||||
state.rotation = 0;
|
||||
state.imageReady = false;
|
||||
try {
|
||||
state.image = await createImageBitmap(asset.blob);
|
||||
state.imageReady = true;
|
||||
} catch (error) {
|
||||
state.imageError = error;
|
||||
}
|
||||
}
|
||||
|
||||
function tick(context, state) {
|
||||
const { ctx, width, height, deltaMs } = context;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const image = state?.image;
|
||||
if (!image || !state.imageReady) {
|
||||
return;
|
||||
}
|
||||
const size = Math.min(width, height) * 0.35;
|
||||
state.rotation = (state.rotation + (deltaMs || 0) * 0.002) % (Math.PI * 2);
|
||||
ctx.save();
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate(state.rotation);
|
||||
ctx.drawImage(image, -size / 2, -size / 2, size, size);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export class BroadcastRenderer {
|
||||
this.scriptWorker = null;
|
||||
this.scriptWorkerReady = false;
|
||||
this.scriptErrorKeys = new Set();
|
||||
this.scriptAttachmentCache = new Map();
|
||||
|
||||
this.obsBrowser = !!globalThis.obsstudio;
|
||||
this.supportsAnimatedDecode =
|
||||
@@ -487,12 +488,12 @@ export class BroadcastRenderer {
|
||||
payload: {
|
||||
id: asset.id,
|
||||
source: assetSource,
|
||||
attachments: asset.scriptAttachments || [],
|
||||
attachments: await this.resolveScriptAttachments(asset.scriptAttachments),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateScriptWorkerAttachments(asset) {
|
||||
async updateScriptWorkerAttachments(asset) {
|
||||
if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -500,7 +501,7 @@ export class BroadcastRenderer {
|
||||
type: "updateAttachments",
|
||||
payload: {
|
||||
id: asset.id,
|
||||
attachments: asset.scriptAttachments || [],
|
||||
attachments: await this.resolveScriptAttachments(asset.scriptAttachments),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -514,4 +515,35 @@ export class BroadcastRenderer {
|
||||
payload: { id: assetId },
|
||||
});
|
||||
}
|
||||
|
||||
async resolveScriptAttachments(attachments) {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const resolved = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
if (!attachment?.url || !attachment.mediaType?.startsWith("image/")) {
|
||||
return attachment;
|
||||
}
|
||||
const cacheKey = attachment.id || attachment.url;
|
||||
const cached = this.scriptAttachmentCache.get(cacheKey);
|
||||
if (cached?.blob) {
|
||||
return { ...attachment, blob: cached.blob };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(attachment.url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch script attachment");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
this.scriptAttachmentCache.set(cacheKey, { blob });
|
||||
return { ...attachment, blob };
|
||||
} catch (error) {
|
||||
console.error("Unable to load script attachment", error);
|
||||
return attachment;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.service.AssetStorageService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.DefaultMarketplaceScript;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
@@ -58,6 +60,7 @@ class ChannelDirectoryServiceTest {
|
||||
private AudioAssetRepository audioAssetRepository;
|
||||
private ScriptAssetRepository scriptAssetRepository;
|
||||
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||
private ScriptAssetFileRepository scriptAssetFileRepository;
|
||||
private SettingsService settingsService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -69,6 +72,7 @@ class ChannelDirectoryServiceTest {
|
||||
audioAssetRepository = mock(AudioAssetRepository.class);
|
||||
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
||||
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
||||
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
|
||||
settingsService = mock(SettingsService.class);
|
||||
when(settingsService.get()).thenReturn(Settings.defaults());
|
||||
setupInMemoryPersistence();
|
||||
@@ -86,6 +90,7 @@ class ChannelDirectoryServiceTest {
|
||||
audioAssetRepository,
|
||||
scriptAssetRepository,
|
||||
scriptAssetAttachmentRepository,
|
||||
scriptAssetFileRepository,
|
||||
messagingTemplate,
|
||||
assetStorageService,
|
||||
mediaDetectionService,
|
||||
@@ -173,6 +178,16 @@ class ChannelDirectoryServiceTest {
|
||||
assertThat(view.zIndex()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesDefaultMarketplaceScript() {
|
||||
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
|
||||
|
||||
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null);
|
||||
|
||||
assertThat(entries)
|
||||
.anyMatch((entry) -> DefaultMarketplaceScript.SCRIPT_ID.equals(entry.id()));
|
||||
}
|
||||
|
||||
private byte[] samplePng() throws IOException {
|
||||
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
|
||||
Reference in New Issue
Block a user