mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Add gif -> video support
This commit is contained in:
@@ -33,6 +33,8 @@ public class Asset {
|
||||
private Double speed;
|
||||
private Boolean muted;
|
||||
private String mediaType;
|
||||
private String originalMediaType;
|
||||
private Integer zIndex;
|
||||
private boolean hidden;
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -51,6 +53,7 @@ public class Asset {
|
||||
this.rotation = 0;
|
||||
this.speed = 1.0;
|
||||
this.muted = false;
|
||||
this.zIndex = 0;
|
||||
this.hidden = false;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
@@ -74,6 +77,9 @@ public class Asset {
|
||||
if (this.muted == null) {
|
||||
this.muted = Boolean.FALSE;
|
||||
}
|
||||
if (this.zIndex == null) {
|
||||
this.zIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -168,6 +174,14 @@ public class Asset {
|
||||
this.mediaType = mediaType;
|
||||
}
|
||||
|
||||
public String getOriginalMediaType() {
|
||||
return originalMediaType;
|
||||
}
|
||||
|
||||
public void setOriginalMediaType(String originalMediaType) {
|
||||
this.originalMediaType = originalMediaType;
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/");
|
||||
}
|
||||
@@ -188,6 +202,14 @@ public class Asset {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Integer getZIndex() {
|
||||
return zIndex == null ? 0 : zIndex;
|
||||
}
|
||||
|
||||
public void setZIndex(Integer zIndex) {
|
||||
this.zIndex = zIndex;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public class TransformRequest {
|
||||
private double rotation;
|
||||
private Double speed;
|
||||
private Boolean muted;
|
||||
private Integer zIndex;
|
||||
|
||||
public double getX() {
|
||||
return x;
|
||||
@@ -64,4 +65,12 @@ public class TransformRequest {
|
||||
public void setMuted(Boolean muted) {
|
||||
this.muted = muted;
|
||||
}
|
||||
|
||||
public Integer getZIndex() {
|
||||
return zIndex;
|
||||
}
|
||||
|
||||
public void setZIndex(Integer zIndex) {
|
||||
this.zIndex = zIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.api.awt.AWTSequenceEncoder;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
import org.jcodec.common.model.Picture;
|
||||
import org.slf4j.Logger;
|
||||
@@ -21,21 +22,30 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
@Service
|
||||
public class ChannelDirectoryService {
|
||||
private static final int MIN_GIF_DELAY_MS = 20;
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||
private final ChannelRepository channelRepository;
|
||||
private final AssetRepository assetRepository;
|
||||
@@ -76,11 +86,11 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
public Collection<Asset> getAssetsForAdmin(String broadcaster) {
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster));
|
||||
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster)));
|
||||
}
|
||||
|
||||
public Collection<Asset> getVisibleAssets(String broadcaster) {
|
||||
return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
|
||||
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||
@@ -115,9 +125,11 @@ public class ChannelDirectoryService {
|
||||
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.setOriginalMediaType(mediaType);
|
||||
asset.setMediaType(optimized.mediaType());
|
||||
asset.setSpeed(1.0);
|
||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
|
||||
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||
@@ -134,6 +146,9 @@ public class ChannelDirectoryService {
|
||||
asset.setWidth(request.getWidth());
|
||||
asset.setHeight(request.getHeight());
|
||||
asset.setRotation(request.getRotation());
|
||||
if (request.getZIndex() != null) {
|
||||
asset.setZIndex(request.getZIndex());
|
||||
}
|
||||
if (request.getSpeed() != null && request.getSpeed() > 0) {
|
||||
asset.setSpeed(request.getSpeed());
|
||||
}
|
||||
@@ -200,6 +215,20 @@ public class ChannelDirectoryService {
|
||||
return value == null ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
private List<Asset> sortByZIndex(Collection<Asset> assets) {
|
||||
return assets.stream()
|
||||
.sorted(Comparator.comparingInt(Asset::getZIndex)
|
||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int nextZIndex(String broadcaster) {
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
|
||||
.mapToInt(Asset::getZIndex)
|
||||
.max()
|
||||
.orElse(0) + 1;
|
||||
}
|
||||
|
||||
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()) {
|
||||
@@ -230,6 +259,13 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
|
||||
if ("image/gif".equalsIgnoreCase(mediaType)) {
|
||||
OptimizedAsset transcoded = transcodeGifToVideo(bytes);
|
||||
if (transcoded != null) {
|
||||
return transcoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("image/") && !"image/gif".equalsIgnoreCase(mediaType)) {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
@@ -259,6 +295,99 @@ public class ChannelDirectoryService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private OptimizedAsset transcodeGifToVideo(byte[] bytes) {
|
||||
try {
|
||||
List<GifFrame> frames = readGifFrames(bytes);
|
||||
if (frames.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int baseDelay = frames.stream()
|
||||
.mapToInt(frame -> normalizeDelay(frame.delayMs()))
|
||||
.reduce(this::greatestCommonDivisor)
|
||||
.orElse(100);
|
||||
int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay));
|
||||
File temp = File.createTempFile("gif-convert", ".mp4");
|
||||
temp.deleteOnExit();
|
||||
try {
|
||||
AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, fps);
|
||||
for (GifFrame frame : frames) {
|
||||
int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay);
|
||||
for (int i = 0; i < repeats; i++) {
|
||||
encoder.encodeImage(frame.image());
|
||||
}
|
||||
}
|
||||
encoder.finish();
|
||||
BufferedImage cover = frames.get(0).image();
|
||||
byte[] video = Files.readAllBytes(temp.toPath());
|
||||
return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight());
|
||||
} finally {
|
||||
Files.deleteIfExists(temp.toPath());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Unable to transcode GIF to video", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<GifFrame> readGifFrames(byte[] bytes) throws IOException {
|
||||
try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
|
||||
var readers = ImageIO.getImageReadersByFormatName("gif");
|
||||
if (!readers.hasNext()) {
|
||||
return List.of();
|
||||
}
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(stream, false, false);
|
||||
int count = reader.getNumImages(true);
|
||||
var frames = new java.util.ArrayList<GifFrame>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
BufferedImage image = reader.read(i);
|
||||
IIOMetadata metadata = reader.getImageMetadata(i);
|
||||
int delay = extractDelayMs(metadata);
|
||||
frames.add(new GifFrame(image, delay));
|
||||
}
|
||||
return frames;
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int extractDelayMs(IIOMetadata metadata) {
|
||||
if (metadata == null) {
|
||||
return 100;
|
||||
}
|
||||
try {
|
||||
String format = metadata.getNativeMetadataFormatName();
|
||||
Node root = metadata.getAsTree(format);
|
||||
NodeList children = root.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node node = children.item(i);
|
||||
if ("GraphicControlExtension".equals(node.getNodeName()) && node.getAttributes() != null) {
|
||||
Node delay = node.getAttributes().getNamedItem("delayTime");
|
||||
if (delay != null) {
|
||||
int hundredths = Integer.parseInt(delay.getNodeValue());
|
||||
return Math.max(hundredths * 10, MIN_GIF_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("Unable to parse GIF delay", e);
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
private int normalizeDelay(int delayMs) {
|
||||
return Math.max(delayMs, MIN_GIF_DELAY_MS);
|
||||
}
|
||||
|
||||
private int greatestCommonDivisor(int a, int b) {
|
||||
if (b == 0) {
|
||||
return Math.max(a, 1);
|
||||
}
|
||||
return greatestCommonDivisor(b, a % b);
|
||||
}
|
||||
|
||||
private byte[] compressPng(BufferedImage image) throws IOException {
|
||||
var writers = ImageIO.getImageWritersByFormatName("png");
|
||||
if (!writers.hasNext()) {
|
||||
@@ -299,5 +428,7 @@ public class ChannelDirectoryService {
|
||||
|
||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
|
||||
|
||||
private record GifFrame(BufferedImage image, int delayMs) { }
|
||||
|
||||
private record Dimension(int width, int height) { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user