Add video support

This commit is contained in:
2025-12-09 15:35:59 +01:00
parent 3dd485ca89
commit 033498f630
9 changed files with 409 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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