mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add video support
This commit is contained in:
@@ -24,6 +24,7 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ensureChannelCanvasColumns();
|
||||
ensureAssetMediaColumns();
|
||||
}
|
||||
|
||||
private void ensureChannelCanvasColumns() {
|
||||
@@ -40,20 +41,39 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
addColumnIfMissing(columns, "canvas_width", "REAL", "1920");
|
||||
addColumnIfMissing(columns, "canvas_height", "REAL", "1080");
|
||||
addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920");
|
||||
addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080");
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(List<String> existingColumns, String columnName, String dataType, String defaultValue) {
|
||||
private void ensureAssetMediaColumns() {
|
||||
List<String> columns;
|
||||
try {
|
||||
columns = jdbcTemplate.query("PRAGMA table_info(assets)", (rs, rowNum) -> rs.getString("name"));
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to inspect assets table for media columns", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (columns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addColumnIfMissing("assets", columns, "speed", "REAL", "1.0");
|
||||
addColumnIfMissing("assets", columns, "muted", "BOOLEAN", "0");
|
||||
addColumnIfMissing("assets", columns, "media_type", "TEXT", "'application/octet-stream'");
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(String tableName, List<String> existingColumns, String columnName, String dataType, String defaultValue) {
|
||||
if (existingColumns.contains(columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE channels ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue);
|
||||
logger.info("Added missing column '{}' to channels table", columnName);
|
||||
jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue);
|
||||
jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL");
|
||||
logger.info("Added missing column '{}' to {} table", columnName, tableName);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Failed to add column '{}' to channels table", columnName, ex);
|
||||
logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ public class Asset {
|
||||
private double width;
|
||||
private double height;
|
||||
private double rotation;
|
||||
private Double speed;
|
||||
private Boolean muted;
|
||||
private String mediaType;
|
||||
private boolean hidden;
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -46,6 +49,8 @@ public class Asset {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.rotation = 0;
|
||||
this.speed = 1.0;
|
||||
this.muted = false;
|
||||
this.hidden = false;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
@@ -63,6 +68,12 @@ public class Asset {
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
if (this.speed == null || this.speed <= 0) {
|
||||
this.speed = 1.0;
|
||||
}
|
||||
if (this.muted == null) {
|
||||
this.muted = Boolean.FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -133,6 +144,34 @@ public class Asset {
|
||||
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 boolean isVideo() {
|
||||
return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/");
|
||||
}
|
||||
|
||||
public boolean isHidden() {
|
||||
return hidden;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public class TransformRequest {
|
||||
private double width;
|
||||
private double height;
|
||||
private double rotation;
|
||||
private Double speed;
|
||||
private Boolean muted;
|
||||
|
||||
public double getX() {
|
||||
return x;
|
||||
@@ -46,4 +48,20 @@ public class TransformRequest {
|
||||
public void setRotation(double rotation) {
|
||||
this.rotation = rotation;
|
||||
}
|
||||
|
||||
public Double getSpeed() {
|
||||
return speed;
|
||||
}
|
||||
|
||||
public void setSpeed(Double speed) {
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
public Boolean getMuted() {
|
||||
return muted;
|
||||
}
|
||||
|
||||
public void setMuted(Boolean muted) {
|
||||
this.muted = muted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,35 @@ import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.VisibilityRequest;
|
||||
import com.imgfloat.app.repository.AssetRepository;
|
||||
import com.imgfloat.app.repository.ChannelRepository;
|
||||
import org.jcodec.api.FrameGrab;
|
||||
import org.jcodec.api.JCodecException;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
import org.jcodec.common.model.Picture;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
|
||||
@Service
|
||||
public class ChannelDirectoryService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||
private final ChannelRepository channelRepository;
|
||||
private final AssetRepository assetRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
@@ -85,17 +99,26 @@ public class ChannelDirectoryService {
|
||||
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = file.getBytes();
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
String mediaType = detectMediaType(file, bytes);
|
||||
|
||||
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
|
||||
if (optimized == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String name = Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(filename -> filename.replaceAll("^.*[/\\\\]", ""))
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElse("Asset " + System.currentTimeMillis());
|
||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
||||
String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes);
|
||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, image.getWidth(), image.getHeight());
|
||||
|
||||
String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes());
|
||||
double width = optimized.width() > 0 ? optimized.width() : 640;
|
||||
double height = optimized.height() > 0 ? optimized.height() : 360;
|
||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
||||
asset.setMediaType(optimized.mediaType());
|
||||
asset.setSpeed(1.0);
|
||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||
return Optional.of(asset);
|
||||
@@ -111,6 +134,12 @@ public class ChannelDirectoryService {
|
||||
asset.setWidth(request.getWidth());
|
||||
asset.setHeight(request.getHeight());
|
||||
asset.setRotation(request.getRotation());
|
||||
if (request.getSpeed() != null && request.getSpeed() > 0) {
|
||||
asset.setSpeed(request.getSpeed());
|
||||
}
|
||||
if (request.getMuted() != null && asset.isVideo()) {
|
||||
asset.setMuted(request.getMuted());
|
||||
}
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
|
||||
return asset;
|
||||
@@ -170,4 +199,105 @@ public class ChannelDirectoryService {
|
||||
private String normalize(String value) {
|
||||
return value == null ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
private String detectMediaType(MultipartFile file, byte[] bytes) {
|
||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
||||
if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
try (var stream = new ByteArrayInputStream(bytes)) {
|
||||
String guessed = URLConnection.guessContentTypeFromStream(stream);
|
||||
if (guessed != null && !guessed.isBlank()) {
|
||||
return guessed;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Unable to detect content type from stream", e);
|
||||
}
|
||||
|
||||
return Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(ext -> switch (ext) {
|
||||
case "png" -> "image/png";
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "gif" -> "image/gif";
|
||||
case "mp4" -> "video/mp4";
|
||||
case "webm" -> "video/webm";
|
||||
case "mov" -> "video/quicktime";
|
||||
default -> "application/octet-stream";
|
||||
})
|
||||
.orElse("application/octet-stream");
|
||||
}
|
||||
|
||||
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
|
||||
if (mediaType.startsWith("image/") && !"image/gif".equalsIgnoreCase(mediaType)) {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] compressed = compressPng(image);
|
||||
return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight());
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight());
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("video/")) {
|
||||
var dimensions = extractVideoDimensions(bytes);
|
||||
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height());
|
||||
}
|
||||
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image != null) {
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private byte[] compressPng(BufferedImage image) throws IOException {
|
||||
var writers = ImageIO.getImageWritersByFormatName("png");
|
||||
if (!writers.hasNext()) {
|
||||
logger.warn("No PNG writer available; skipping compression");
|
||||
try (ByteArrayOutputStream fallback = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, "png", fallback);
|
||||
return fallback.toByteArray();
|
||||
}
|
||||
}
|
||||
ImageWriter writer = writers.next();
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||
writer.setOutput(ios);
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
if (param.canWriteCompressed()) {
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(1.0f);
|
||||
}
|
||||
writer.write(null, new IIOImage(image, null, null), param);
|
||||
return baos.toByteArray();
|
||||
} finally {
|
||||
writer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private Dimension extractVideoDimensions(byte[] bytes) {
|
||||
try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
|
||||
FrameGrab grab = FrameGrab.createFrameGrab(channel);
|
||||
Picture frame = grab.getNativeFrame();
|
||||
if (frame != null) {
|
||||
return new Dimension(frame.getWidth(), frame.getHeight());
|
||||
}
|
||||
} catch (IOException | JCodecException e) {
|
||||
logger.warn("Unable to read video dimensions", e);
|
||||
}
|
||||
return new Dimension(640, 360);
|
||||
}
|
||||
|
||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
|
||||
|
||||
private record Dimension(int width, int height) { }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ spring:
|
||||
import: optional:file:.env[.properties]
|
||||
application:
|
||||
name: imgfloat
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 256MB
|
||||
max-request-size: 256MB
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
|
||||
@@ -7,7 +7,7 @@ let canvasSettings = { width: 1920, height: 1080 };
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const mediaCache = new Map();
|
||||
const renderStates = new Map();
|
||||
let selectedAssetId = null;
|
||||
let interactionState = null;
|
||||
@@ -20,12 +20,16 @@ const controlsPanel = document.getElementById('asset-controls');
|
||||
const widthInput = document.getElementById('asset-width');
|
||||
const heightInput = document.getElementById('asset-height');
|
||||
const aspectLockInput = document.getElementById('maintain-aspect');
|
||||
const speedInput = document.getElementById('asset-speed');
|
||||
const muteInput = document.getElementById('asset-muted');
|
||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||
const aspectLockState = new Map();
|
||||
|
||||
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
|
||||
if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height'));
|
||||
if (speedInput) speedInput.addEventListener('change', updatePlaybackFromInputs);
|
||||
if (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
|
||||
|
||||
function connect() {
|
||||
const socket = new SockJS('/ws');
|
||||
@@ -84,14 +88,14 @@ function renderAssets(list) {
|
||||
function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
imageCache.delete(event.assetId);
|
||||
mediaCache.delete(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
if (selectedAssetId === event.assetId) {
|
||||
selectedAssetId = null;
|
||||
}
|
||||
} else if (event.payload) {
|
||||
assets.set(event.payload.id, event.payload);
|
||||
ensureImage(event.payload);
|
||||
ensureMedia(event.payload);
|
||||
}
|
||||
drawAndList();
|
||||
}
|
||||
@@ -114,10 +118,11 @@ function drawAsset(asset) {
|
||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
const media = ensureMedia(asset);
|
||||
const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete);
|
||||
if (ready) {
|
||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||
ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
} else {
|
||||
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
||||
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
||||
@@ -362,17 +367,54 @@ function startRenderLoop() {
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function ensureImage(asset) {
|
||||
const cached = imageCache.get(asset.id);
|
||||
function isVideoAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/');
|
||||
}
|
||||
|
||||
function isVideoElement(element) {
|
||||
return element && element.tagName === 'VIDEO';
|
||||
}
|
||||
|
||||
function ensureMedia(asset) {
|
||||
const cached = mediaCache.get(asset.id);
|
||||
if (cached && cached.src === asset.url) {
|
||||
applyMediaSettings(cached, asset);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.onload = draw;
|
||||
image.src = asset.url;
|
||||
imageCache.set(asset.id, image);
|
||||
return image;
|
||||
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
||||
if (isVideoElement(element)) {
|
||||
element.loop = true;
|
||||
element.muted = asset.muted ?? true;
|
||||
element.playsInline = true;
|
||||
element.autoplay = true;
|
||||
element.onloadeddata = draw;
|
||||
element.src = asset.url;
|
||||
element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
element.play().catch(() => {});
|
||||
} else {
|
||||
element.onload = draw;
|
||||
element.src = asset.url;
|
||||
}
|
||||
mediaCache.set(asset.id, element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function applyMediaSettings(element, asset) {
|
||||
if (!isVideoElement(element)) {
|
||||
return;
|
||||
}
|
||||
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
if (element.playbackRate !== nextSpeed) {
|
||||
element.playbackRate = nextSpeed;
|
||||
}
|
||||
const shouldMute = asset.muted ?? true;
|
||||
if (element.muted !== shouldMute) {
|
||||
element.muted = shouldMute;
|
||||
}
|
||||
if (element.paused) {
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
@@ -400,10 +442,7 @@ function renderAssetList() {
|
||||
li.classList.add('hidden');
|
||||
}
|
||||
|
||||
const preview = document.createElement('img');
|
||||
preview.className = 'asset-preview';
|
||||
preview.src = asset.url;
|
||||
preview.alt = asset.name || 'Asset preview';
|
||||
const preview = createPreviewElement(asset);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
@@ -454,6 +493,26 @@ function renderAssetList() {
|
||||
updateSelectedAssetControls();
|
||||
}
|
||||
|
||||
function createPreviewElement(asset) {
|
||||
if (isVideoAsset(asset)) {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'asset-preview';
|
||||
video.src = asset.url;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
video.play().catch(() => {});
|
||||
return video;
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'asset-preview';
|
||||
img.src = asset.url;
|
||||
img.alt = asset.name || 'Asset preview';
|
||||
return img;
|
||||
}
|
||||
|
||||
function getSelectedAsset() {
|
||||
return selectedAssetId ? assets.get(selectedAssetId) : null;
|
||||
}
|
||||
@@ -479,6 +538,14 @@ function updateSelectedAssetControls() {
|
||||
aspectLockInput.checked = isAspectLocked(asset.id);
|
||||
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
|
||||
}
|
||||
if (speedInput) {
|
||||
speedInput.value = Math.round((asset.speed && asset.speed > 0 ? asset.speed : 1) * 100) / 100;
|
||||
}
|
||||
if (muteInput) {
|
||||
muteInput.checked = !!asset.muted;
|
||||
muteInput.disabled = !isVideoAsset(asset);
|
||||
muteInput.parentElement?.classList.toggle('disabled', !isVideoAsset(asset));
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransformFromInputs() {
|
||||
@@ -506,6 +573,29 @@ function applyTransformFromInputs() {
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function updatePlaybackFromInputs() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const nextSpeed = Math.max(0.1, parseFloat(speedInput?.value) || asset.speed || 1);
|
||||
asset.speed = nextSpeed;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function updateMuteFromInput() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset || !isVideoAsset(asset)) return;
|
||||
asset.muted = !!muteInput?.checked;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
persistTransform(asset);
|
||||
const media = mediaCache.get(asset.id);
|
||||
if (media) {
|
||||
applyMediaSettings(media, asset);
|
||||
}
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function nudgeRotation(delta) {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
@@ -529,9 +619,12 @@ function recenterSelectedAsset() {
|
||||
}
|
||||
|
||||
function getAssetAspectRatio(asset) {
|
||||
const image = ensureImage(asset);
|
||||
if (image?.naturalWidth && image?.naturalHeight) {
|
||||
return image.naturalWidth / image.naturalHeight;
|
||||
const media = ensureMedia(asset);
|
||||
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
|
||||
return media.videoWidth / media.videoHeight;
|
||||
}
|
||||
if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) {
|
||||
return media.naturalWidth / media.naturalHeight;
|
||||
}
|
||||
if (asset.width && asset.height) {
|
||||
return asset.width / asset.height;
|
||||
@@ -584,7 +677,7 @@ function updateVisibility(asset, hidden) {
|
||||
function deleteAsset(asset) {
|
||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
|
||||
assets.delete(asset.id);
|
||||
imageCache.delete(asset.id);
|
||||
mediaCache.delete(asset.id);
|
||||
renderStates.delete(asset.id);
|
||||
if (selectedAssetId === asset.id) {
|
||||
selectedAssetId = null;
|
||||
@@ -596,7 +689,7 @@ function deleteAsset(asset) {
|
||||
function uploadAsset() {
|
||||
const fileInput = document.getElementById('asset-file');
|
||||
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
||||
alert('Please choose an image to upload.');
|
||||
alert('Please choose an image, GIF, or video to upload.');
|
||||
return;
|
||||
}
|
||||
const data = new FormData();
|
||||
@@ -646,7 +739,9 @@ function persistTransform(asset) {
|
||||
y: asset.y,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
rotation: asset.rotation
|
||||
rotation: asset.rotation,
|
||||
speed: asset.speed,
|
||||
muted: asset.muted
|
||||
})
|
||||
}).then((r) => r.json()).then((updated) => {
|
||||
assets.set(updated.id, updated);
|
||||
|
||||
@@ -4,7 +4,7 @@ let canvasSettings = { width: 1920, height: 1080 };
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const mediaCache = new Map();
|
||||
const renderStates = new Map();
|
||||
let animationFrameId = null;
|
||||
|
||||
@@ -51,14 +51,14 @@ function resizeCanvas() {
|
||||
function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
imageCache.delete(event.assetId);
|
||||
mediaCache.delete(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
} else if (event.payload && !event.payload.hidden) {
|
||||
assets.set(event.payload.id, event.payload);
|
||||
ensureImage(event.payload);
|
||||
ensureMedia(event.payload);
|
||||
} else if (event.payload && event.payload.hidden) {
|
||||
assets.delete(event.payload.id);
|
||||
imageCache.delete(event.payload.id);
|
||||
mediaCache.delete(event.payload.id);
|
||||
renderStates.delete(event.payload.id);
|
||||
}
|
||||
draw();
|
||||
@@ -77,9 +77,10 @@ function drawAsset(asset) {
|
||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
const media = ensureMedia(asset);
|
||||
const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete);
|
||||
if (ready) {
|
||||
ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -108,17 +109,54 @@ function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function ensureImage(asset) {
|
||||
const cached = imageCache.get(asset.id);
|
||||
function isVideoAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/');
|
||||
}
|
||||
|
||||
function isVideoElement(element) {
|
||||
return element && element.tagName === 'VIDEO';
|
||||
}
|
||||
|
||||
function ensureMedia(asset) {
|
||||
const cached = mediaCache.get(asset.id);
|
||||
if (cached && cached.src === asset.url) {
|
||||
applyMediaSettings(cached, asset);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.onload = draw;
|
||||
image.src = asset.url;
|
||||
imageCache.set(asset.id, image);
|
||||
return image;
|
||||
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
||||
if (isVideoElement(element)) {
|
||||
element.loop = true;
|
||||
element.muted = asset.muted ?? true;
|
||||
element.playsInline = true;
|
||||
element.autoplay = true;
|
||||
element.onloadeddata = draw;
|
||||
element.src = asset.url;
|
||||
element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
element.play().catch(() => {});
|
||||
} else {
|
||||
element.onload = draw;
|
||||
element.src = asset.url;
|
||||
}
|
||||
mediaCache.set(asset.id, element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function applyMediaSettings(element, asset) {
|
||||
if (!isVideoElement(element)) {
|
||||
return;
|
||||
}
|
||||
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
if (element.playbackRate !== nextSpeed) {
|
||||
element.playbackRate = nextSpeed;
|
||||
}
|
||||
const shouldMute = asset.muted ?? true;
|
||||
if (element.muted !== shouldMute) {
|
||||
element.muted = shouldMute;
|
||||
}
|
||||
if (element.paused) {
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function startRenderLoop() {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div>
|
||||
<h3>Overlay assets</h3>
|
||||
<p>Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.</p>
|
||||
<input id="asset-file" type="file" accept="image/*" />
|
||||
<input id="asset-file" type="file" accept="image/*,video/*" />
|
||||
<button onclick="uploadAsset()">Upload</button>
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
<div id="asset-controls" class="panel hidden">
|
||||
@@ -44,6 +44,16 @@
|
||||
Maintain aspect ratio
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Animation speed
|
||||
<input id="asset-speed" type="number" min="0.1" step="0.1" value="1" />
|
||||
</label>
|
||||
<label class="checkbox-inline">
|
||||
<input id="asset-muted" type="checkbox" />
|
||||
Muted (videos)
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
|
||||
|
||||
Reference in New Issue
Block a user