mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add gif -> video support
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -64,6 +64,12 @@
|
|||||||
<version>0.2.5</version>
|
<version>0.2.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jcodec</groupId>
|
||||||
|
<artifactId>jcodec-javase</artifactId>
|
||||||
|
<version>0.2.5</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.session</groupId>
|
<groupId>org.springframework.session</groupId>
|
||||||
<artifactId>spring-session-jdbc</artifactId>
|
<artifactId>spring-session-jdbc</artifactId>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public class Asset {
|
|||||||
private Double speed;
|
private Double speed;
|
||||||
private Boolean muted;
|
private Boolean muted;
|
||||||
private String mediaType;
|
private String mediaType;
|
||||||
|
private String originalMediaType;
|
||||||
|
private Integer zIndex;
|
||||||
private boolean hidden;
|
private boolean hidden;
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ public class Asset {
|
|||||||
this.rotation = 0;
|
this.rotation = 0;
|
||||||
this.speed = 1.0;
|
this.speed = 1.0;
|
||||||
this.muted = false;
|
this.muted = false;
|
||||||
|
this.zIndex = 0;
|
||||||
this.hidden = false;
|
this.hidden = false;
|
||||||
this.createdAt = Instant.now();
|
this.createdAt = Instant.now();
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,9 @@ public class Asset {
|
|||||||
if (this.muted == null) {
|
if (this.muted == null) {
|
||||||
this.muted = Boolean.FALSE;
|
this.muted = Boolean.FALSE;
|
||||||
}
|
}
|
||||||
|
if (this.zIndex == null) {
|
||||||
|
this.zIndex = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getId() {
|
public String getId() {
|
||||||
@@ -168,6 +174,14 @@ public class Asset {
|
|||||||
this.mediaType = mediaType;
|
this.mediaType = mediaType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getOriginalMediaType() {
|
||||||
|
return originalMediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalMediaType(String originalMediaType) {
|
||||||
|
this.originalMediaType = originalMediaType;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isVideo() {
|
public boolean isVideo() {
|
||||||
return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/");
|
return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/");
|
||||||
}
|
}
|
||||||
@@ -188,6 +202,14 @@ public class Asset {
|
|||||||
this.createdAt = createdAt;
|
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) {
|
private static String normalize(String value) {
|
||||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class TransformRequest {
|
|||||||
private double rotation;
|
private double rotation;
|
||||||
private Double speed;
|
private Double speed;
|
||||||
private Boolean muted;
|
private Boolean muted;
|
||||||
|
private Integer zIndex;
|
||||||
|
|
||||||
public double getX() {
|
public double getX() {
|
||||||
return x;
|
return x;
|
||||||
@@ -64,4 +65,12 @@ public class TransformRequest {
|
|||||||
public void setMuted(Boolean muted) {
|
public void setMuted(Boolean muted) {
|
||||||
this.muted = 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 com.imgfloat.app.repository.ChannelRepository;
|
||||||
import org.jcodec.api.FrameGrab;
|
import org.jcodec.api.FrameGrab;
|
||||||
import org.jcodec.api.JCodecException;
|
import org.jcodec.api.JCodecException;
|
||||||
|
import org.jcodec.api.awt.AWTSequenceEncoder;
|
||||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||||
import org.jcodec.common.model.Picture;
|
import org.jcodec.common.model.Picture;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -21,21 +22,30 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageReader;
|
||||||
import javax.imageio.ImageWriteParam;
|
import javax.imageio.ImageWriteParam;
|
||||||
import javax.imageio.ImageWriter;
|
import javax.imageio.ImageWriter;
|
||||||
import javax.imageio.IIOImage;
|
import javax.imageio.IIOImage;
|
||||||
|
import javax.imageio.metadata.IIOMetadata;
|
||||||
import javax.imageio.stream.ImageOutputStream;
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
|
import javax.imageio.stream.ImageInputStream;
|
||||||
|
import org.w3c.dom.Node;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ChannelDirectoryService {
|
public class ChannelDirectoryService {
|
||||||
|
private static final int MIN_GIF_DELAY_MS = 20;
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
@@ -76,11 +86,11 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Collection<Asset> getAssetsForAdmin(String broadcaster) {
|
public Collection<Asset> getAssetsForAdmin(String broadcaster) {
|
||||||
return assetRepository.findByBroadcaster(normalize(broadcaster));
|
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<Asset> getVisibleAssets(String broadcaster) {
|
public Collection<Asset> getVisibleAssets(String broadcaster) {
|
||||||
return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
|
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||||
@@ -115,9 +125,11 @@ public class ChannelDirectoryService {
|
|||||||
double width = optimized.width() > 0 ? optimized.width() : 640;
|
double width = optimized.width() > 0 ? optimized.width() : 640;
|
||||||
double height = optimized.height() > 0 ? optimized.height() : 360;
|
double height = optimized.height() > 0 ? optimized.height() : 360;
|
||||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
||||||
|
asset.setOriginalMediaType(mediaType);
|
||||||
asset.setMediaType(optimized.mediaType());
|
asset.setMediaType(optimized.mediaType());
|
||||||
asset.setSpeed(1.0);
|
asset.setSpeed(1.0);
|
||||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||||
|
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
|
||||||
|
|
||||||
assetRepository.save(asset);
|
assetRepository.save(asset);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||||
@@ -134,6 +146,9 @@ public class ChannelDirectoryService {
|
|||||||
asset.setWidth(request.getWidth());
|
asset.setWidth(request.getWidth());
|
||||||
asset.setHeight(request.getHeight());
|
asset.setHeight(request.getHeight());
|
||||||
asset.setRotation(request.getRotation());
|
asset.setRotation(request.getRotation());
|
||||||
|
if (request.getZIndex() != null) {
|
||||||
|
asset.setZIndex(request.getZIndex());
|
||||||
|
}
|
||||||
if (request.getSpeed() != null && request.getSpeed() > 0) {
|
if (request.getSpeed() != null && request.getSpeed() > 0) {
|
||||||
asset.setSpeed(request.getSpeed());
|
asset.setSpeed(request.getSpeed());
|
||||||
}
|
}
|
||||||
@@ -200,6 +215,20 @@ public class ChannelDirectoryService {
|
|||||||
return value == null ? null : value.toLowerCase();
|
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) {
|
private String detectMediaType(MultipartFile file, byte[] bytes) {
|
||||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
||||||
if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) {
|
if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) {
|
||||||
@@ -230,6 +259,13 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
|
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)) {
|
if (mediaType.startsWith("image/") && !"image/gif".equalsIgnoreCase(mediaType)) {
|
||||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
@@ -259,6 +295,99 @@ public class ChannelDirectoryService {
|
|||||||
return null;
|
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 {
|
private byte[] compressPng(BufferedImage image) throws IOException {
|
||||||
var writers = ImageIO.getImageWritersByFormatName("png");
|
var writers = ImageIO.getImageWritersByFormatName("png");
|
||||||
if (!writers.hasNext()) {
|
if (!writers.hasNext()) {
|
||||||
@@ -299,5 +428,7 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
|
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) { }
|
private record Dimension(int width, int height) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ body {
|
|||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: rgba(148, 163, 184, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-list {
|
.feature-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ canvas.height = canvasSettings.height;
|
|||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const mediaCache = new Map();
|
const mediaCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
|
const animatedCache = new Map();
|
||||||
let selectedAssetId = null;
|
let selectedAssetId = null;
|
||||||
let interactionState = null;
|
let interactionState = null;
|
||||||
let animationFrameId = null;
|
let animationFrameId = null;
|
||||||
@@ -24,6 +25,8 @@ const speedInput = document.getElementById('asset-speed');
|
|||||||
const muteInput = document.getElementById('asset-muted');
|
const muteInput = document.getElementById('asset-muted');
|
||||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||||
|
const selectedZLabel = document.getElementById('asset-z-level');
|
||||||
|
const selectedTypeLabel = document.getElementById('asset-type-label');
|
||||||
const aspectLockState = new Map();
|
const aspectLockState = new Map();
|
||||||
|
|
||||||
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
|
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
|
||||||
@@ -88,7 +91,7 @@ function renderAssets(list) {
|
|||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(event.assetId);
|
||||||
mediaCache.delete(event.assetId);
|
clearMedia(event.assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(event.assetId);
|
||||||
if (selectedAssetId === event.assetId) {
|
if (selectedAssetId === event.assetId) {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
@@ -107,7 +110,20 @@ function drawAndList() {
|
|||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
assets.forEach((asset) => drawAsset(asset));
|
getZOrderedAssets().forEach((asset) => drawAsset(asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZOrderedAssets() {
|
||||||
|
return Array.from(assets.values()).sort(zComparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zComparator(a, b) {
|
||||||
|
const aZ = a?.zIndex ?? 0;
|
||||||
|
const bZ = b?.zIndex ?? 0;
|
||||||
|
if (aZ !== bZ) {
|
||||||
|
return aZ - bZ;
|
||||||
|
}
|
||||||
|
return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAsset(asset) {
|
function drawAsset(asset) {
|
||||||
@@ -119,10 +135,11 @@ function drawAsset(asset) {
|
|||||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const media = ensureMedia(asset);
|
const media = ensureMedia(asset);
|
||||||
const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete);
|
const drawSource = media?.isAnimated ? media.bitmap : media;
|
||||||
|
const ready = isDrawable(media);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||||
ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
} else {
|
} else {
|
||||||
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
||||||
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
||||||
@@ -375,6 +392,47 @@ function isVideoElement(element) {
|
|||||||
return element && element.tagName === 'VIDEO';
|
return element && element.tagName === 'VIDEO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDisplayMediaType(asset) {
|
||||||
|
const raw = asset.originalMediaType || asset.mediaType || '';
|
||||||
|
if (!raw) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
const parts = raw.split('/');
|
||||||
|
return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGifAsset(asset) {
|
||||||
|
return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDrawable(element) {
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.isAnimated) {
|
||||||
|
return !!element.bitmap;
|
||||||
|
}
|
||||||
|
if (isVideoElement(element)) {
|
||||||
|
return element.readyState >= 2;
|
||||||
|
}
|
||||||
|
if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!element.complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMedia(assetId) {
|
||||||
|
mediaCache.delete(assetId);
|
||||||
|
const animated = animatedCache.get(assetId);
|
||||||
|
if (animated) {
|
||||||
|
animated.cancelled = true;
|
||||||
|
clearTimeout(animated.timeout);
|
||||||
|
animated.bitmap?.close?.();
|
||||||
|
animated.decoder?.close?.();
|
||||||
|
animatedCache.delete(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureMedia(asset) {
|
function ensureMedia(asset) {
|
||||||
const cached = mediaCache.get(asset.id);
|
const cached = mediaCache.get(asset.id);
|
||||||
if (cached && cached.src === asset.url) {
|
if (cached && cached.src === asset.url) {
|
||||||
@@ -382,6 +440,14 @@ function ensureMedia(asset) {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
||||||
|
const animated = ensureAnimatedImage(asset);
|
||||||
|
if (animated) {
|
||||||
|
mediaCache.set(asset.id, animated);
|
||||||
|
return animated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
||||||
if (isVideoElement(element)) {
|
if (isVideoElement(element)) {
|
||||||
element.loop = true;
|
element.loop = true;
|
||||||
@@ -400,6 +466,83 @@ function ensureMedia(asset) {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAnimatedImage(asset) {
|
||||||
|
const cached = animatedCache.get(asset.id);
|
||||||
|
if (cached && cached.url === asset.url) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
clearMedia(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
id: asset.id,
|
||||||
|
url: asset.url,
|
||||||
|
src: asset.url,
|
||||||
|
decoder: null,
|
||||||
|
bitmap: null,
|
||||||
|
timeout: null,
|
||||||
|
cancelled: false,
|
||||||
|
isAnimated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(asset.url)
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' }))
|
||||||
|
.then((decoder) => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
decoder.close?.();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
controller.decoder = decoder;
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
return controller;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
animatedCache.delete(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
animatedCache.set(asset.id, controller);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextFrame(controller) {
|
||||||
|
if (controller.cancelled || !controller.decoder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.decoder.decode().then(({ image, complete }) => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
image.close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.bitmap?.close?.();
|
||||||
|
createImageBitmap(image)
|
||||||
|
.then((bitmap) => {
|
||||||
|
controller.bitmap = bitmap;
|
||||||
|
draw();
|
||||||
|
})
|
||||||
|
.finally(() => image.close?.());
|
||||||
|
|
||||||
|
const durationMicros = image.duration || 0;
|
||||||
|
const delay = durationMicros > 0 ? durationMicros / 1000 : 100;
|
||||||
|
const hasMore = !complete;
|
||||||
|
controller.timeout = setTimeout(() => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasMore) {
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
} else {
|
||||||
|
controller.decoder.reset();
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}).catch(() => {
|
||||||
|
animatedCache.delete(controller.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyMediaSettings(element, asset) {
|
function applyMediaSettings(element, asset) {
|
||||||
if (!isVideoElement(element)) {
|
if (!isVideoElement(element)) {
|
||||||
return;
|
return;
|
||||||
@@ -429,9 +572,7 @@ function renderAssetList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedAssets = Array.from(assets.values()).sort(
|
const sortedAssets = getZOrderedAssets().reverse();
|
||||||
(a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
|
|
||||||
);
|
|
||||||
sortedAssets.forEach((asset) => {
|
sortedAssets.forEach((asset) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'asset-item';
|
li.className = 'asset-item';
|
||||||
@@ -449,7 +590,7 @@ function renderAssetList() {
|
|||||||
const name = document.createElement('strong');
|
const name = document.createElement('strong');
|
||||||
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||||
const details = document.createElement('small');
|
const details = document.createElement('small');
|
||||||
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
details.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||||
meta.appendChild(name);
|
meta.appendChild(name);
|
||||||
meta.appendChild(details);
|
meta.appendChild(details);
|
||||||
|
|
||||||
@@ -530,7 +671,13 @@ function updateSelectedAssetControls() {
|
|||||||
controlsPanel.classList.remove('hidden');
|
controlsPanel.classList.remove('hidden');
|
||||||
lastSizeInputChanged = null;
|
lastSizeInputChanged = null;
|
||||||
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||||
selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
selectedAssetMeta.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||||
|
if (selectedZLabel) {
|
||||||
|
selectedZLabel.textContent = asset.zIndex ?? 0;
|
||||||
|
}
|
||||||
|
if (selectedTypeLabel) {
|
||||||
|
selectedTypeLabel.textContent = getDisplayMediaType(asset);
|
||||||
|
}
|
||||||
|
|
||||||
if (widthInput) widthInput.value = Math.round(asset.width);
|
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||||
if (heightInput) heightInput.value = Math.round(asset.height);
|
if (heightInput) heightInput.value = Math.round(asset.height);
|
||||||
@@ -618,6 +765,56 @@ function recenterSelectedAsset() {
|
|||||||
drawAndList();
|
drawAndList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bringForward() {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const ordered = getZOrderedAssets();
|
||||||
|
const index = ordered.findIndex((item) => item.id === asset.id);
|
||||||
|
if (index === -1 || index === ordered.length - 1) return;
|
||||||
|
[ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]];
|
||||||
|
applyZOrder(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringBackward() {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const ordered = getZOrderedAssets();
|
||||||
|
const index = ordered.findIndex((item) => item.id === asset.id);
|
||||||
|
if (index <= 0) return;
|
||||||
|
[ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]];
|
||||||
|
applyZOrder(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringToFront() {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
|
||||||
|
ordered.push(asset);
|
||||||
|
applyZOrder(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToBack() {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
|
||||||
|
ordered.unshift(asset);
|
||||||
|
applyZOrder(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZOrder(ordered) {
|
||||||
|
const changed = [];
|
||||||
|
ordered.forEach((item, index) => {
|
||||||
|
if ((item.zIndex ?? 0) !== index) {
|
||||||
|
item.zIndex = index;
|
||||||
|
changed.push(item);
|
||||||
|
}
|
||||||
|
assets.set(item.id, item);
|
||||||
|
renderStates.set(item.id, { ...item });
|
||||||
|
});
|
||||||
|
changed.forEach((item) => persistTransform(item, true));
|
||||||
|
drawAndList();
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetAspectRatio(asset) {
|
function getAssetAspectRatio(asset) {
|
||||||
const media = ensureMedia(asset);
|
const media = ensureMedia(asset);
|
||||||
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
|
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
|
||||||
@@ -726,11 +923,11 @@ function isPointOnAsset(asset, x, y) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findAssetAtPoint(x, y) {
|
function findAssetAtPoint(x, y) {
|
||||||
const ordered = Array.from(assets.values()).reverse();
|
const ordered = getZOrderedAssets().reverse();
|
||||||
return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null;
|
return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistTransform(asset) {
|
function persistTransform(asset, silent = false) {
|
||||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
|
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -741,11 +938,14 @@ function persistTransform(asset) {
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
rotation: asset.rotation,
|
rotation: asset.rotation,
|
||||||
speed: asset.speed,
|
speed: asset.speed,
|
||||||
muted: asset.muted
|
muted: asset.muted,
|
||||||
|
zIndex: asset.zIndex
|
||||||
})
|
})
|
||||||
}).then((r) => r.json()).then((updated) => {
|
}).then((r) => r.json()).then((updated) => {
|
||||||
assets.set(updated.id, updated);
|
assets.set(updated.id, updated);
|
||||||
|
if (!silent) {
|
||||||
drawAndList();
|
drawAndList();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ canvas.height = canvasSettings.height;
|
|||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const mediaCache = new Map();
|
const mediaCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
|
const animatedCache = new Map();
|
||||||
let animationFrameId = null;
|
let animationFrameId = null;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -51,14 +52,14 @@ function resizeCanvas() {
|
|||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(event.assetId);
|
||||||
mediaCache.delete(event.assetId);
|
clearMedia(event.assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(event.assetId);
|
||||||
} else if (event.payload && !event.payload.hidden) {
|
} else if (event.payload && !event.payload.hidden) {
|
||||||
assets.set(event.payload.id, event.payload);
|
assets.set(event.payload.id, event.payload);
|
||||||
ensureMedia(event.payload);
|
ensureMedia(event.payload);
|
||||||
} else if (event.payload && event.payload.hidden) {
|
} else if (event.payload && event.payload.hidden) {
|
||||||
assets.delete(event.payload.id);
|
assets.delete(event.payload.id);
|
||||||
mediaCache.delete(event.payload.id);
|
clearMedia(event.payload.id);
|
||||||
renderStates.delete(event.payload.id);
|
renderStates.delete(event.payload.id);
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
@@ -66,7 +67,20 @@ function handleEvent(event) {
|
|||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
assets.forEach(drawAsset);
|
getZOrderedAssets().forEach(drawAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZOrderedAssets() {
|
||||||
|
return Array.from(assets.values()).sort(zComparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zComparator(a, b) {
|
||||||
|
const aZ = a?.zIndex ?? 0;
|
||||||
|
const bZ = b?.zIndex ?? 0;
|
||||||
|
if (aZ !== bZ) {
|
||||||
|
return aZ - bZ;
|
||||||
|
}
|
||||||
|
return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAsset(asset) {
|
function drawAsset(asset) {
|
||||||
@@ -78,9 +92,10 @@ function drawAsset(asset) {
|
|||||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const media = ensureMedia(asset);
|
const media = ensureMedia(asset);
|
||||||
const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete);
|
const drawSource = media?.isAnimated ? media.bitmap : media;
|
||||||
|
const ready = isDrawable(media);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -117,6 +132,38 @@ function isVideoElement(element) {
|
|||||||
return element && element.tagName === 'VIDEO';
|
return element && element.tagName === 'VIDEO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGifAsset(asset) {
|
||||||
|
return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDrawable(element) {
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.isAnimated) {
|
||||||
|
return !!element.bitmap;
|
||||||
|
}
|
||||||
|
if (isVideoElement(element)) {
|
||||||
|
return element.readyState >= 2;
|
||||||
|
}
|
||||||
|
if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!element.complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMedia(assetId) {
|
||||||
|
mediaCache.delete(assetId);
|
||||||
|
const animated = animatedCache.get(assetId);
|
||||||
|
if (animated) {
|
||||||
|
animated.cancelled = true;
|
||||||
|
clearTimeout(animated.timeout);
|
||||||
|
animated.bitmap?.close?.();
|
||||||
|
animated.decoder?.close?.();
|
||||||
|
animatedCache.delete(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureMedia(asset) {
|
function ensureMedia(asset) {
|
||||||
const cached = mediaCache.get(asset.id);
|
const cached = mediaCache.get(asset.id);
|
||||||
if (cached && cached.src === asset.url) {
|
if (cached && cached.src === asset.url) {
|
||||||
@@ -124,6 +171,14 @@ function ensureMedia(asset) {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
||||||
|
const animated = ensureAnimatedImage(asset);
|
||||||
|
if (animated) {
|
||||||
|
mediaCache.set(asset.id, animated);
|
||||||
|
return animated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
||||||
if (isVideoElement(element)) {
|
if (isVideoElement(element)) {
|
||||||
element.loop = true;
|
element.loop = true;
|
||||||
@@ -142,6 +197,84 @@ function ensureMedia(asset) {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAnimatedImage(asset) {
|
||||||
|
const cached = animatedCache.get(asset.id);
|
||||||
|
if (cached && cached.url === asset.url) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
clearMedia(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
id: asset.id,
|
||||||
|
url: asset.url,
|
||||||
|
src: asset.url,
|
||||||
|
decoder: null,
|
||||||
|
bitmap: null,
|
||||||
|
timeout: null,
|
||||||
|
cancelled: false,
|
||||||
|
isAnimated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(asset.url)
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' }))
|
||||||
|
.then((decoder) => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
decoder.close?.();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
controller.decoder = decoder;
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
return controller;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
animatedCache.delete(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
animatedCache.set(asset.id, controller);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextFrame(controller) {
|
||||||
|
if (controller.cancelled || !controller.decoder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.decoder.decode().then(({ image, complete }) => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
image.close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.bitmap?.close?.();
|
||||||
|
createImageBitmap(image)
|
||||||
|
.then((bitmap) => {
|
||||||
|
controller.bitmap = bitmap;
|
||||||
|
draw();
|
||||||
|
})
|
||||||
|
.finally(() => image.close?.());
|
||||||
|
|
||||||
|
const durationMicros = image.duration || 0;
|
||||||
|
const delay = durationMicros > 0 ? durationMicros / 1000 : 100;
|
||||||
|
const hasMore = !complete;
|
||||||
|
controller.timeout = setTimeout(() => {
|
||||||
|
if (controller.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasMore) {
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
} else {
|
||||||
|
controller.decoder.reset();
|
||||||
|
scheduleNextFrame(controller);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}).catch(() => {
|
||||||
|
// If decoding fails, clear animated cache so static fallback is used next render
|
||||||
|
animatedCache.delete(controller.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyMediaSettings(element, asset) {
|
function applyMediaSettings(element, asset) {
|
||||||
if (!isVideoElement(element)) {
|
if (!isVideoElement(element)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -54,12 +54,27 @@
|
|||||||
Muted (videos)
|
Muted (videos)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-grid">
|
||||||
|
<label>
|
||||||
|
Layer (Z)
|
||||||
|
<div class="badge-row">
|
||||||
|
<span id="asset-z-level" class="badge">0</span>
|
||||||
|
<span id="asset-type-label" class="badge subtle"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="control-actions">
|
<div class="control-actions">
|
||||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
|
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
|
||||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
|
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
|
||||||
<button type="button" onclick="applyTransformFromInputs()">Apply size</button>
|
<button type="button" onclick="applyTransformFromInputs()">Apply size</button>
|
||||||
<button type="button" onclick="recenterSelectedAsset()" class="secondary">Re-center asset</button>
|
<button type="button" onclick="recenterSelectedAsset()" class="secondary">Re-center asset</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button type="button" onclick="sendToBack()" class="secondary">Send to back</button>
|
||||||
|
<button type="button" onclick="bringBackward()" class="secondary">Bring backward</button>
|
||||||
|
<button type="button" onclick="bringForward()" class="secondary">Bring forward</button>
|
||||||
|
<button type="button" onclick="bringToFront()" class="secondary">Bring to front</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user