mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Improve rdd proc
This commit is contained in:
@@ -66,6 +66,7 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
addColumnIfMissing("assets", columns, "audio_speed", "REAL", "1.0");
|
||||
addColumnIfMissing("assets", columns, "audio_pitch", "REAL", "1.0");
|
||||
addColumnIfMissing("assets", columns, "audio_volume", "REAL", "1.0");
|
||||
addColumnIfMissing("assets", columns, "preview", "TEXT", "NULL");
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(String tableName, List<String> existingColumns, String columnName, String dataType, String defaultValue) {
|
||||
|
||||
@@ -231,6 +231,33 @@ public class ChannelApiController {
|
||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available"));
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/preview")
|
||||
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
boolean authorized = false;
|
||||
if (authentication != null) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
authorized = channelDirectoryService.isBroadcaster(broadcaster, login)
|
||||
|| channelDirectoryService.isAdmin(broadcaster, login);
|
||||
}
|
||||
|
||||
if (authorized) {
|
||||
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetPreview(broadcaster, assetId, true)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||
}
|
||||
|
||||
return channelDirectoryService.getAssetPreview(broadcaster, assetId, false)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}")
|
||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
|
||||
@@ -34,6 +34,8 @@ public class Asset {
|
||||
private Boolean muted;
|
||||
private String mediaType;
|
||||
private String originalMediaType;
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String preview;
|
||||
private Integer zIndex;
|
||||
private Boolean audioLoop;
|
||||
private Integer audioDelayMillis;
|
||||
@@ -202,6 +204,14 @@ public class Asset {
|
||||
this.originalMediaType = originalMediaType;
|
||||
}
|
||||
|
||||
public String getPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public void setPreview(String preview) {
|
||||
this.preview = preview;
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public record AssetView(
|
||||
String broadcaster,
|
||||
String name,
|
||||
String url,
|
||||
String previewUrl,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
@@ -23,6 +24,7 @@ public record AssetView(
|
||||
Double audioPitch,
|
||||
Double audioVolume,
|
||||
boolean hidden,
|
||||
boolean hasPreview,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static AssetView from(String broadcaster, Asset asset) {
|
||||
@@ -31,6 +33,7 @@ public record AssetView(
|
||||
asset.getBroadcaster(),
|
||||
asset.getName(),
|
||||
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
|
||||
asset.getPreview() != null ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null,
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
@@ -47,6 +50,7 @@ public record AssetView(
|
||||
asset.getAudioPitch(),
|
||||
asset.getAudioVolume(),
|
||||
asset.isHidden(),
|
||||
asset.getPreview() != null,
|
||||
asset.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.jcodec.scale.AWTUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
@@ -131,6 +132,7 @@ public class ChannelDirectoryService {
|
||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
||||
asset.setOriginalMediaType(mediaType);
|
||||
asset.setMediaType(optimized.mediaType());
|
||||
asset.setPreview(optimized.previewDataUrl());
|
||||
asset.setSpeed(1.0);
|
||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||
asset.setAudioLoop(false);
|
||||
@@ -240,6 +242,24 @@ public class ChannelDirectoryService {
|
||||
.flatMap(this::decodeAssetData);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetPreview(String broadcaster, String assetId, boolean includeHidden) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
.filter(asset -> includeHidden || !asset.isHidden())
|
||||
.map(asset -> {
|
||||
Optional<AssetContent> preview = decodeDataUrl(asset.getPreview());
|
||||
if (preview.isPresent()) {
|
||||
return preview.get();
|
||||
}
|
||||
if (asset.getMediaType() != null && asset.getMediaType().startsWith("image/")) {
|
||||
return decodeAssetData(asset).orElse(null);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.flatMap(Optional::ofNullable);
|
||||
}
|
||||
|
||||
public boolean isBroadcaster(String broadcaster, String username) {
|
||||
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
|
||||
}
|
||||
@@ -279,23 +299,30 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
||||
String url = asset.getUrl();
|
||||
if (url == null || !url.startsWith("data:")) {
|
||||
return decodeDataUrl(asset.getUrl())
|
||||
.or(() -> {
|
||||
logger.warn("Unable to decode asset data for {}", asset.getId());
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<AssetContent> decodeDataUrl(String dataUrl) {
|
||||
if (dataUrl == null || !dataUrl.startsWith("data:")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int commaIndex = url.indexOf(',');
|
||||
int commaIndex = dataUrl.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String metadata = url.substring(5, commaIndex);
|
||||
String metadata = dataUrl.substring(5, commaIndex);
|
||||
String[] parts = metadata.split(";", 2);
|
||||
String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream";
|
||||
String encoded = url.substring(commaIndex + 1);
|
||||
String encoded = dataUrl.substring(commaIndex + 1);
|
||||
try {
|
||||
byte[] bytes = Base64.getDecoder().decode(encoded);
|
||||
return Optional.of(new AssetContent(bytes, mediaType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Unable to decode asset data for {}", asset.getId(), e);
|
||||
logger.warn("Unable to decode data url", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -353,7 +380,7 @@ public class ChannelDirectoryService {
|
||||
return null;
|
||||
}
|
||||
byte[] compressed = compressPng(image);
|
||||
return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight());
|
||||
return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight(), null);
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
@@ -361,21 +388,22 @@ public class ChannelDirectoryService {
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight());
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null);
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("video/")) {
|
||||
var dimensions = extractVideoDimensions(bytes);
|
||||
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height());
|
||||
String preview = extractVideoPreview(bytes, mediaType);
|
||||
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview);
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("audio/")) {
|
||||
return new OptimizedAsset(bytes, mediaType, 0, 0);
|
||||
return new OptimizedAsset(bytes, mediaType, 0, 0, null);
|
||||
}
|
||||
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image != null) {
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight());
|
||||
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -404,7 +432,7 @@ public class ChannelDirectoryService {
|
||||
encoder.finish();
|
||||
BufferedImage cover = frames.get(0).image();
|
||||
byte[] video = Files.readAllBytes(temp.toPath());
|
||||
return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight());
|
||||
return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), encodePreview(cover));
|
||||
} finally {
|
||||
Files.deleteIfExists(temp.toPath());
|
||||
}
|
||||
@@ -498,6 +526,19 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private String encodePreview(BufferedImage image) {
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, "png", baos);
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Unable to encode preview image", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Dimension extractVideoDimensions(byte[] bytes) {
|
||||
try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
|
||||
FrameGrab grab = FrameGrab.createFrameGrab(channel);
|
||||
@@ -511,9 +552,24 @@ public class ChannelDirectoryService {
|
||||
return new Dimension(640, 360);
|
||||
}
|
||||
|
||||
private String extractVideoPreview(byte[] bytes, String mediaType) {
|
||||
try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
|
||||
FrameGrab grab = FrameGrab.createFrameGrab(channel);
|
||||
Picture frame = grab.getNativeFrame();
|
||||
if (frame == null) {
|
||||
return null;
|
||||
}
|
||||
BufferedImage image = AWTUtil.toBufferedImage(frame);
|
||||
return encodePreview(image);
|
||||
} catch (IOException | JCodecException e) {
|
||||
logger.warn("Unable to capture video preview frame for {}", mediaType, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||
|
||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
|
||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, String previewDataUrl) { }
|
||||
|
||||
private record GifFrame(BufferedImage image, int delayMs) { }
|
||||
|
||||
|
||||
@@ -695,6 +695,11 @@ body {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.subtle-text {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
@@ -720,6 +725,31 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field-note {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stacked-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.value-hint {
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -938,6 +968,11 @@ body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-grid.split-row {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.control-grid.three-col {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
@@ -949,6 +984,11 @@ body {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.control-grid .inline-toggle {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-grid input[type="number"],
|
||||
.control-grid input[type="range"] {
|
||||
padding: 8px;
|
||||
|
||||
@@ -15,6 +15,7 @@ const audioControllers = new Map();
|
||||
const pendingAudioUnlock = new Set();
|
||||
const loopPlaybackState = new Map();
|
||||
const previewCache = new Map();
|
||||
const previewImageCache = new Map();
|
||||
let drawPending = false;
|
||||
let zOrderDirty = true;
|
||||
let zOrderCache = [];
|
||||
@@ -30,6 +31,7 @@ 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 speedLabel = document.getElementById('asset-speed-label');
|
||||
const selectedZLabel = document.getElementById('asset-z-level');
|
||||
const playbackSection = document.getElementById('playback-section');
|
||||
const audioSection = document.getElementById('audio-section');
|
||||
@@ -37,6 +39,7 @@ const layoutSection = document.getElementById('layout-section');
|
||||
const audioLoopInput = document.getElementById('asset-audio-loop');
|
||||
const audioDelayInput = document.getElementById('asset-audio-delay');
|
||||
const audioSpeedInput = document.getElementById('asset-audio-speed');
|
||||
const audioSpeedLabel = document.getElementById('asset-audio-speed-label');
|
||||
const audioPitchInput = document.getElementById('asset-audio-pitch');
|
||||
const audioVolumeInput = document.getElementById('asset-audio-volume');
|
||||
const controlsPlaceholder = document.getElementById('asset-controls-placeholder');
|
||||
@@ -44,6 +47,7 @@ const fileNameLabel = document.getElementById('asset-file-name');
|
||||
const assetInspector = document.getElementById('asset-inspector');
|
||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||
const selectedAssetIdLabel = document.getElementById('selected-asset-id');
|
||||
const selectedAssetBadges = document.getElementById('selected-asset-badges');
|
||||
const selectedVisibilityBtn = document.getElementById('selected-asset-visibility');
|
||||
const selectedDeleteBtn = document.getElementById('selected-asset-delete');
|
||||
@@ -144,6 +148,18 @@ function getDurationBadge(asset) {
|
||||
return formatDurationLabel(asset.durationMs);
|
||||
}
|
||||
|
||||
function setSpeedLabel(percent) {
|
||||
if (!speedLabel) return;
|
||||
speedLabel.textContent = `${Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
function setAudioSpeedLabel(percentValue) {
|
||||
if (!audioSpeedLabel) return;
|
||||
const multiplier = Math.max(0, percentValue) / 100;
|
||||
const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2);
|
||||
audioSpeedLabel.textContent = `${formatted}x`;
|
||||
}
|
||||
|
||||
function queueAudioForUnlock(controller) {
|
||||
if (!controller) return;
|
||||
pendingAudioUnlock.add(controller);
|
||||
@@ -266,20 +282,22 @@ function renderAssets(list) {
|
||||
function storeAsset(asset) {
|
||||
if (!asset) return;
|
||||
const existing = assets.get(asset.id);
|
||||
if (existing && existing.url !== asset.url) {
|
||||
const merged = existing ? { ...existing, ...asset } : { ...asset };
|
||||
const mediaChanged = existing && existing.url !== merged.url;
|
||||
const previewChanged = existing && existing.previewUrl !== merged.previewUrl;
|
||||
if (mediaChanged || previewChanged) {
|
||||
clearMedia(asset.id);
|
||||
previewCache.delete(asset.id);
|
||||
}
|
||||
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
|
||||
const parsedCreatedAt = asset.createdAt ? new Date(asset.createdAt).getTime() : NaN;
|
||||
const hasCreatedAtMs = typeof asset.createdAtMs === 'number' && Number.isFinite(asset.createdAtMs);
|
||||
merged.zIndex = Math.max(1, merged.zIndex ?? 1);
|
||||
const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN;
|
||||
const hasCreatedAtMs = typeof merged.createdAtMs === 'number' && Number.isFinite(merged.createdAtMs);
|
||||
if (!hasCreatedAtMs) {
|
||||
asset.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now();
|
||||
merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now();
|
||||
}
|
||||
assets.set(asset.id, asset);
|
||||
assets.set(asset.id, merged);
|
||||
zOrderDirty = true;
|
||||
if (!renderStates.has(asset.id)) {
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
renderStates.set(asset.id, { ...merged });
|
||||
}
|
||||
resolvePendingUploadByName(asset.name);
|
||||
}
|
||||
@@ -376,9 +394,18 @@ function drawAsset(asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = ensureMedia(asset);
|
||||
const drawSource = media?.isAnimated ? media.bitmap : media;
|
||||
const ready = isDrawable(media);
|
||||
let drawSource = null;
|
||||
let ready = false;
|
||||
let showPlayOverlay = false;
|
||||
if (isVideoAsset(asset) || isGifAsset(asset)) {
|
||||
drawSource = ensureCanvasPreview(asset);
|
||||
ready = isDrawable(drawSource);
|
||||
showPlayOverlay = true;
|
||||
} else {
|
||||
const media = ensureMedia(asset);
|
||||
drawSource = media?.isAnimated ? media.bitmap : media;
|
||||
ready = isDrawable(media);
|
||||
}
|
||||
if (ready && drawSource) {
|
||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
@@ -398,6 +425,9 @@ function drawAsset(asset) {
|
||||
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
||||
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
||||
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
if (showPlayOverlay) {
|
||||
drawPlayOverlay(renderState);
|
||||
}
|
||||
if (asset.id === selectedAssetId) {
|
||||
drawSelectionOverlay(renderState);
|
||||
}
|
||||
@@ -425,6 +455,24 @@ function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function drawPlayOverlay(asset) {
|
||||
const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-size * 0.3, -size * 0.45);
|
||||
ctx.lineTo(size * 0.55, 0);
|
||||
ctx.lineTo(-size * 0.3, size * 0.45);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSelectionOverlay(asset) {
|
||||
const halfWidth = asset.width / 2;
|
||||
const halfHeight = asset.height / 2;
|
||||
@@ -658,7 +706,12 @@ function isDrawable(element) {
|
||||
|
||||
function clearMedia(assetId) {
|
||||
mediaCache.delete(assetId);
|
||||
const cachedPreview = previewCache.get(assetId);
|
||||
if (cachedPreview && cachedPreview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(cachedPreview);
|
||||
}
|
||||
previewCache.delete(assetId);
|
||||
previewImageCache.delete(assetId);
|
||||
const animated = animatedCache.get(assetId);
|
||||
if (animated) {
|
||||
animated.cancelled = true;
|
||||
@@ -964,10 +1017,12 @@ function renderAssetList() {
|
||||
|
||||
const badges = document.createElement('div');
|
||||
badges.className = 'badge-row asset-meta-badges';
|
||||
badges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
|
||||
badges.appendChild(createBadge(getDisplayMediaType(asset)));
|
||||
badges.appendChild(createBadge(`Z ${asset.zIndex ?? 1}`));
|
||||
const aspectLabel = formatAspectRatioLabel(asset);
|
||||
if (!isAudioAsset(asset)) {
|
||||
badges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
|
||||
badges.appendChild(createBadge(`Z ${asset.zIndex ?? 1}`));
|
||||
}
|
||||
const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : '';
|
||||
if (aspectLabel) {
|
||||
badges.appendChild(createBadge(aspectLabel, 'subtle'));
|
||||
}
|
||||
@@ -980,17 +1035,6 @@ function renderAssetList() {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'ghost icon-button';
|
||||
toggleBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`;
|
||||
toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset';
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedAssetId = asset.id;
|
||||
updateVisibility(asset, !asset.hidden);
|
||||
});
|
||||
|
||||
if (isAudioAsset(asset)) {
|
||||
const playBtn = document.createElement('button');
|
||||
playBtn.type = 'button';
|
||||
@@ -1016,6 +1060,20 @@ function renderAssetList() {
|
||||
actions.appendChild(playBtn);
|
||||
}
|
||||
|
||||
if (!isAudioAsset(asset)) {
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'ghost icon-button';
|
||||
toggleBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`;
|
||||
toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset';
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedAssetId = asset.id;
|
||||
updateVisibility(asset, !asset.hidden);
|
||||
});
|
||||
actions.appendChild(toggleBtn);
|
||||
}
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'ghost danger icon-button';
|
||||
@@ -1026,7 +1084,6 @@ function renderAssetList() {
|
||||
deleteAsset(asset);
|
||||
});
|
||||
|
||||
actions.appendChild(toggleBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(preview);
|
||||
@@ -1136,26 +1193,50 @@ function createPreviewElement(asset) {
|
||||
return img;
|
||||
}
|
||||
|
||||
function loadPreviewFrame(asset, element) {
|
||||
if (!asset || !element) return;
|
||||
function fetchPreviewData(asset) {
|
||||
if (!asset) return Promise.resolve(null);
|
||||
const cached = previewCache.get(asset.id);
|
||||
if (cached) {
|
||||
applyPreviewFrame(element, cached);
|
||||
return;
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const source = isVideoAsset(asset)
|
||||
? captureVideoFrame(asset)
|
||||
: isGifAsset(asset)
|
||||
? captureGifFrame(asset)
|
||||
: Promise.resolve(null);
|
||||
const primary = asset.previewUrl
|
||||
? fetch(asset.previewUrl)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('preview fetch failed');
|
||||
return r.blob();
|
||||
})
|
||||
.then((blob) => URL.createObjectURL(blob))
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
source
|
||||
return primary
|
||||
.then((dataUrl) => {
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
if (dataUrl) {
|
||||
previewCache.set(asset.id, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
previewCache.set(asset.id, dataUrl);
|
||||
const fallback = isVideoAsset(asset)
|
||||
? captureVideoFrame(asset)
|
||||
: isGifAsset(asset)
|
||||
? captureGifFrame(asset)
|
||||
: Promise.resolve(null);
|
||||
return fallback.then((result) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
previewCache.set(asset.id, result);
|
||||
return result;
|
||||
});
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
function loadPreviewFrame(asset, element) {
|
||||
if (!asset || !element) return;
|
||||
fetchPreviewData(asset)
|
||||
.then((dataUrl) => {
|
||||
if (!dataUrl) return;
|
||||
applyPreviewFrame(element, dataUrl);
|
||||
})
|
||||
.catch(() => { });
|
||||
@@ -1167,6 +1248,36 @@ function applyPreviewFrame(element, dataUrl) {
|
||||
element.classList.add('has-image');
|
||||
}
|
||||
|
||||
function ensureCanvasPreview(asset) {
|
||||
const cachedData = previewCache.get(asset.id);
|
||||
const cachedImage = previewImageCache.get(asset.id);
|
||||
if (cachedData && cachedImage?.src === cachedData) {
|
||||
return cachedImage.image;
|
||||
}
|
||||
|
||||
if (cachedData) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = requestDraw;
|
||||
img.src = cachedData;
|
||||
previewImageCache.set(asset.id, { src: cachedData, image: img });
|
||||
return img;
|
||||
}
|
||||
|
||||
fetchPreviewData(asset)
|
||||
.then((dataUrl) => {
|
||||
if (!dataUrl) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = requestDraw;
|
||||
img.src = dataUrl;
|
||||
previewImageCache.set(asset.id, { src: dataUrl, image: img });
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function captureVideoFrame(asset) {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
@@ -1273,6 +1384,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
|
||||
if (speedInput) {
|
||||
const percent = Math.round((asset.speed ?? 1) * 100);
|
||||
speedInput.value = Math.min(1000, Math.max(0, percent));
|
||||
setSpeedLabel(speedInput.value);
|
||||
}
|
||||
if (playbackSection) {
|
||||
const shouldShowPlayback = isVideoAsset(asset);
|
||||
@@ -1297,6 +1409,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
|
||||
audioLoopInput.checked = !!asset.audioLoop;
|
||||
audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0);
|
||||
audioSpeedInput.value = Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100);
|
||||
setAudioSpeedLabel(audioSpeedInput.value);
|
||||
audioPitchInput.value = Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100);
|
||||
audioVolumeInput.value = Math.round(Math.max(0, Math.min(1, asset.audioVolume ?? 1)) * 100);
|
||||
}
|
||||
@@ -1312,16 +1425,29 @@ function updateSelectedAssetSummary(asset) {
|
||||
selectedAssetName.textContent = asset ? (asset.name || `Asset ${asset.id.slice(0, 6)}`) : 'Choose an asset';
|
||||
}
|
||||
if (selectedAssetMeta) {
|
||||
const baseMeta = asset ? `${Math.round(asset.width)}x${Math.round(asset.height)}` : null;
|
||||
const layerMeta = asset && !isAudioAsset(asset) ? ` · Layer ${asset.zIndex ?? 1}` : '';
|
||||
selectedAssetMeta.textContent = asset
|
||||
? `${Math.round(asset.width)}x${Math.round(asset.height)} · Layer ${asset.zIndex ?? 1}`
|
||||
? `${baseMeta}${layerMeta}`
|
||||
: 'Pick an asset in the list to adjust its placement and playback.';
|
||||
}
|
||||
if (selectedAssetIdLabel) {
|
||||
if (asset) {
|
||||
selectedAssetIdLabel.textContent = `ID: ${asset.id}`;
|
||||
selectedAssetIdLabel.classList.remove('hidden');
|
||||
} else {
|
||||
selectedAssetIdLabel.classList.add('hidden');
|
||||
selectedAssetIdLabel.textContent = '';
|
||||
}
|
||||
}
|
||||
if (selectedAssetBadges) {
|
||||
selectedAssetBadges.innerHTML = '';
|
||||
if (asset) {
|
||||
selectedAssetBadges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
|
||||
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
|
||||
const aspectLabel = formatAspectRatioLabel(asset);
|
||||
if (!isAudioAsset(asset)) {
|
||||
selectedAssetBadges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
|
||||
}
|
||||
const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : '';
|
||||
if (aspectLabel) {
|
||||
selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle'));
|
||||
}
|
||||
@@ -1373,6 +1499,7 @@ function updatePlaybackFromInputs() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset || !isVideoAsset(asset)) return;
|
||||
const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100));
|
||||
setSpeedLabel(percent);
|
||||
asset.speed = percent / 100;
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
@@ -1401,7 +1528,9 @@ function updateAudioSettingsFromInputs() {
|
||||
if (!asset || !isAudioAsset(asset)) return;
|
||||
asset.audioLoop = !!audioLoopInput?.checked;
|
||||
asset.audioDelayMillis = Math.max(0, parseInt(audioDelayInput?.value || '0', 10));
|
||||
asset.audioSpeed = Math.max(0.25, (parseInt(audioSpeedInput?.value || '100', 10) / 100));
|
||||
const nextAudioSpeedPercent = Math.max(25, parseInt(audioSpeedInput?.value || '100', 10));
|
||||
setAudioSpeedLabel(nextAudioSpeedPercent);
|
||||
asset.audioSpeed = Math.max(0.25, (nextAudioSpeedPercent / 100));
|
||||
asset.audioPitch = Math.max(0.5, (parseInt(audioPitchInput?.value || '100', 10) / 100));
|
||||
asset.audioVolume = Math.max(0, Math.min(1, (parseInt(audioVolumeInput?.value || '100', 10) / 100)));
|
||||
const controller = ensureAudioController(asset);
|
||||
@@ -1499,6 +1628,9 @@ function getAssetAspectRatio(asset) {
|
||||
}
|
||||
|
||||
function formatAspectRatioLabel(asset) {
|
||||
if (isAudioAsset(asset)) {
|
||||
return '';
|
||||
}
|
||||
const ratio = getAssetAspectRatio(asset);
|
||||
if (!ratio) {
|
||||
return '';
|
||||
|
||||
@@ -7,6 +7,8 @@ const assets = new Map();
|
||||
const mediaCache = new Map();
|
||||
const renderStates = new Map();
|
||||
const animatedCache = new Map();
|
||||
const blobCache = new Map();
|
||||
const animationFailures = new Map();
|
||||
const audioControllers = new Map();
|
||||
const pendingAudioUnlock = new Set();
|
||||
const TARGET_FPS = 60;
|
||||
@@ -283,6 +285,8 @@ function clearMedia(assetId) {
|
||||
animated.decoder?.close?.();
|
||||
animatedCache.delete(assetId);
|
||||
}
|
||||
animationFailures.delete(assetId);
|
||||
blobCache.delete(assetId);
|
||||
const audio = audioControllers.get(assetId);
|
||||
if (audio) {
|
||||
if (audio.delayTimeout) {
|
||||
@@ -485,11 +489,17 @@ function ensureMedia(asset) {
|
||||
}
|
||||
|
||||
function ensureAnimatedImage(asset) {
|
||||
const failedAt = animationFailures.get(asset.id);
|
||||
if (failedAt && Date.now() - failedAt < 15000) {
|
||||
return null;
|
||||
}
|
||||
const cached = animatedCache.get(asset.id);
|
||||
if (cached && cached.url === asset.url) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
animationFailures.delete(asset.id);
|
||||
|
||||
if (cached) {
|
||||
clearMedia(asset.id);
|
||||
}
|
||||
@@ -505,8 +515,7 @@ function ensureAnimatedImage(asset) {
|
||||
isAnimated: true
|
||||
};
|
||||
|
||||
fetch(asset.url)
|
||||
.then((r) => r.blob())
|
||||
fetchAssetBlob(asset)
|
||||
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' }))
|
||||
.then((decoder) => {
|
||||
if (controller.cancelled) {
|
||||
@@ -519,12 +528,32 @@ function ensureAnimatedImage(asset) {
|
||||
})
|
||||
.catch(() => {
|
||||
animatedCache.delete(asset.id);
|
||||
animationFailures.set(asset.id, Date.now());
|
||||
});
|
||||
|
||||
animatedCache.set(asset.id, controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function fetchAssetBlob(asset) {
|
||||
const cached = blobCache.get(asset.id);
|
||||
if (cached && cached.url === asset.url && cached.blob) {
|
||||
return Promise.resolve(cached.blob);
|
||||
}
|
||||
if (cached && cached.url === asset.url && cached.pending) {
|
||||
return cached.pending;
|
||||
}
|
||||
|
||||
const pending = fetch(asset.url)
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
blobCache.set(asset.id, { url: asset.url, blob });
|
||||
return blob;
|
||||
});
|
||||
blobCache.set(asset.id, { url: asset.url, pending });
|
||||
return pending;
|
||||
}
|
||||
|
||||
function scheduleNextFrame(controller) {
|
||||
if (controller.cancelled || !controller.decoder) {
|
||||
return;
|
||||
@@ -559,6 +588,7 @@ function scheduleNextFrame(controller) {
|
||||
}).catch(() => {
|
||||
// If decoding fails, clear animated cache so static fallback is used next render
|
||||
animatedCache.delete(controller.id);
|
||||
animationFailures.set(controller.id, Date.now());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<strong id="selected-asset-name">Choose an asset</strong>
|
||||
</div>
|
||||
<p class="meta-text" id="selected-asset-meta">Pick an asset in the list to adjust its placement and playback.</p>
|
||||
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
|
||||
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
|
||||
</div>
|
||||
<!--
|
||||
@@ -116,19 +117,23 @@
|
||||
<div class="panel-section" id="playback-section">
|
||||
<div class="section-header">
|
||||
<h5>Playback</h5>
|
||||
<p class="field-note">Video-only controls.</p>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<label>
|
||||
Animation speed
|
||||
<input id="asset-speed" class="range-input" type="range" min="0" max="1000" step="10" value="100" />
|
||||
</label>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Animation speed</span>
|
||||
<span class="value-hint" id="asset-speed-label">100%</span>
|
||||
</div>
|
||||
<input id="asset-speed" class="range-input" type="range" min="0" max="1000" step="10" value="100" />
|
||||
<div class="range-meta"><span>0%</span><span>1000%</span></div>
|
||||
<label class="checkbox-inline toggle">
|
||||
</div>
|
||||
<div class="control-grid condensed split-row">
|
||||
<label class="checkbox-inline toggle inline-toggle">
|
||||
<input id="asset-muted" type="checkbox" />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Mute</span>
|
||||
<span class="toggle-label">Mute video</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,8 +142,9 @@
|
||||
<div class="section-header">
|
||||
<h5>Audio</h5>
|
||||
</div>
|
||||
<div class="control-grid condensed three-col">
|
||||
<label class="checkbox-inline toggle">
|
||||
<div class="control-grid condensed two-col">
|
||||
|
||||
<label class="checkbox-inline toggle inline-toggle">
|
||||
<input id="asset-audio-loop" type="checkbox" />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
@@ -149,6 +155,16 @@
|
||||
Delay (ms)
|
||||
<input id="asset-audio-delay" class="number-input" type="number" min="0" step="100" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback speed</span>
|
||||
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
|
||||
</div>
|
||||
<input id="asset-audio-speed" class="range-input" type="range" min="25" max="400" step="5" value="100" />
|
||||
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
|
||||
</div>
|
||||
<div class="control-grid condensed two-col">
|
||||
<label>
|
||||
Pitch (%)
|
||||
<input id="asset-audio-pitch" class="range-input" type="range" min="50" max="200" step="5" value="100" />
|
||||
@@ -158,13 +174,6 @@
|
||||
<input id="asset-audio-volume" class="range-input" type="range" min="0" max="100" step="1" value="100" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<label>
|
||||
Playback speed
|
||||
<input id="asset-audio-speed" class="range-input" type="range" min="25" max="400" step="5" value="100" />
|
||||
</label>
|
||||
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user