mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add translation controls
This commit is contained in:
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
@@ -19,6 +20,9 @@ public class Asset {
|
||||
@Column(nullable = false)
|
||||
private String broadcaster;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String url;
|
||||
private double x;
|
||||
@@ -32,9 +36,10 @@ public class Asset {
|
||||
public Asset() {
|
||||
}
|
||||
|
||||
public Asset(String broadcaster, String url, double width, double height) {
|
||||
public Asset(String broadcaster, String name, String url, double width, double height) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
@@ -46,6 +51,7 @@ public class Asset {
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void prepare() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
@@ -54,6 +60,9 @@ public class Asset {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -68,6 +77,14 @@ public class Asset {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,13 @@ public class ChannelDirectoryService {
|
||||
if (image == 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(), dataUrl, image.getWidth(), image.getHeight());
|
||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, image.getWidth(), image.getHeight());
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||
return Optional.of(asset);
|
||||
|
||||
@@ -102,6 +102,21 @@ body {
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -128,6 +143,7 @@ body {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-item.selected {
|
||||
@@ -153,3 +169,49 @@ body {
|
||||
.asset-item.hidden {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.asset-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.control-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.control-grid input[type="number"],
|
||||
.control-grid input[type="range"] {
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.range-value {
|
||||
color: #a5b4fc;
|
||||
font-size: 12px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,22 @@ canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const renderStates = new Map();
|
||||
let selectedAssetId = null;
|
||||
let dragState = null;
|
||||
let animationFrameId = null;
|
||||
|
||||
const controlsPanel = document.getElementById('asset-controls');
|
||||
const widthInput = document.getElementById('asset-width');
|
||||
const heightInput = document.getElementById('asset-height');
|
||||
const rotationInput = document.getElementById('asset-rotation');
|
||||
const rotationDisplay = document.getElementById('rotation-display');
|
||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||
|
||||
if (rotationInput) {
|
||||
rotationInput.addEventListener('input', updateRotationDisplay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const socket = new SockJS('/ws');
|
||||
@@ -33,6 +47,7 @@ function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
imageCache.delete(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
if (selectedAssetId === event.assetId) {
|
||||
selectedAssetId = null;
|
||||
}
|
||||
@@ -54,33 +69,68 @@ function draw() {
|
||||
}
|
||||
|
||||
function drawAsset(asset) {
|
||||
const renderState = smoothState(asset);
|
||||
ctx.save();
|
||||
ctx.translate(asset.x, asset.y);
|
||||
ctx.rotate(asset.rotation * Math.PI / 180);
|
||||
ctx.translate(renderState.x, renderState.y);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||
ctx.drawImage(image, 0, 0, asset.width, asset.height);
|
||||
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||
} else {
|
||||
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
||||
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
||||
ctx.fillRect(0, 0, asset.width, asset.height);
|
||||
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
if (asset.hidden) {
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
|
||||
ctx.fillRect(0, 0, asset.width, asset.height);
|
||||
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
||||
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
||||
ctx.strokeRect(0, 0, asset.width, asset.height);
|
||||
ctx.strokeRect(0, 0, renderState.width, renderState.height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function smoothState(asset) {
|
||||
const previous = renderStates.get(asset.id) || { ...asset };
|
||||
const factor = dragState && dragState.assetId === asset.id ? 0.5 : 0.18;
|
||||
const next = {
|
||||
x: lerp(previous.x, asset.x, factor),
|
||||
y: lerp(previous.y, asset.y, factor),
|
||||
width: lerp(previous.width, asset.width, factor),
|
||||
height: lerp(previous.height, asset.height, factor),
|
||||
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
|
||||
};
|
||||
renderStates.set(asset.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function smoothAngle(current, target, factor) {
|
||||
let delta = ((target - current + 180) % 360) - 180;
|
||||
return current + delta * factor;
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function startRenderLoop() {
|
||||
if (animationFrameId) {
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
draw();
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function ensureImage(asset) {
|
||||
const cached = imageCache.get(asset.id);
|
||||
if (cached && cached.src === asset.url) {
|
||||
@@ -102,6 +152,7 @@ function renderAssetList() {
|
||||
const empty = document.createElement('li');
|
||||
empty.textContent = 'No assets yet. Upload to get started.';
|
||||
list.appendChild(empty);
|
||||
updateSelectedAssetControls();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,10 +169,15 @@ function renderAssetList() {
|
||||
li.classList.add('hidden');
|
||||
}
|
||||
|
||||
const preview = document.createElement('img');
|
||||
preview.className = 'asset-preview';
|
||||
preview.src = asset.url;
|
||||
preview.alt = asset.name || 'Asset preview';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
const name = document.createElement('strong');
|
||||
name.textContent = `Asset ${asset.id.slice(0, 6)}`;
|
||||
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||
const details = document.createElement('small');
|
||||
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||
meta.appendChild(name);
|
||||
@@ -154,13 +210,76 @@ function renderAssetList() {
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectedAssetId = asset.id;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
drawAndList();
|
||||
});
|
||||
|
||||
li.appendChild(preview);
|
||||
li.appendChild(meta);
|
||||
li.appendChild(actions);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
updateSelectedAssetControls();
|
||||
}
|
||||
|
||||
function getSelectedAsset() {
|
||||
return selectedAssetId ? assets.get(selectedAssetId) : null;
|
||||
}
|
||||
|
||||
function updateSelectedAssetControls() {
|
||||
if (!controlsPanel) {
|
||||
return;
|
||||
}
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) {
|
||||
controlsPanel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
controlsPanel.classList.remove('hidden');
|
||||
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'}`;
|
||||
|
||||
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||
if (heightInput) heightInput.value = Math.round(asset.height);
|
||||
if (rotationInput) {
|
||||
rotationInput.value = Math.round(asset.rotation);
|
||||
updateRotationDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransformFromInputs() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const nextWidth = parseFloat(widthInput?.value) || asset.width;
|
||||
const nextHeight = parseFloat(heightInput?.value) || asset.height;
|
||||
const nextRotation = parseFloat(rotationInput?.value) || 0;
|
||||
|
||||
asset.width = Math.max(10, nextWidth);
|
||||
asset.height = Math.max(10, nextHeight);
|
||||
asset.rotation = nextRotation;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function nudgeRotation(delta) {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const next = (asset.rotation || 0) + delta;
|
||||
if (rotationInput) rotationInput.value = next;
|
||||
asset.rotation = next;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRotationDisplay();
|
||||
persistTransform(asset);
|
||||
}
|
||||
|
||||
function updateRotationDisplay() {
|
||||
if (rotationDisplay && rotationInput) {
|
||||
const value = Math.round(parseFloat(rotationInput.value || '0'));
|
||||
rotationDisplay.textContent = `${value}°`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibility(asset, hidden) {
|
||||
@@ -178,6 +297,7 @@ function deleteAsset(asset) {
|
||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
|
||||
assets.delete(asset.id);
|
||||
imageCache.delete(asset.id);
|
||||
renderStates.delete(asset.id);
|
||||
if (selectedAssetId === asset.id) {
|
||||
selectedAssetId = null;
|
||||
}
|
||||
@@ -296,4 +416,5 @@ window.addEventListener('resize', () => {
|
||||
draw();
|
||||
});
|
||||
|
||||
startRenderLoop();
|
||||
connect();
|
||||
|
||||
@@ -4,6 +4,8 @@ canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const renderStates = new Map();
|
||||
let animationFrameId = null;
|
||||
|
||||
function connect() {
|
||||
const socket = new SockJS('/ws');
|
||||
@@ -26,12 +28,14 @@ function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
imageCache.delete(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
} else if (event.payload && !event.payload.hidden) {
|
||||
assets.set(event.payload.id, event.payload);
|
||||
ensureImage(event.payload);
|
||||
} else if (event.payload && event.payload.hidden) {
|
||||
assets.delete(event.payload.id);
|
||||
imageCache.delete(event.payload.id);
|
||||
renderStates.delete(event.payload.id);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
@@ -42,18 +46,42 @@ function draw() {
|
||||
}
|
||||
|
||||
function drawAsset(asset) {
|
||||
const renderState = smoothState(asset);
|
||||
ctx.save();
|
||||
ctx.translate(asset.x, asset.y);
|
||||
ctx.rotate(asset.rotation * Math.PI / 180);
|
||||
ctx.translate(renderState.x, renderState.y);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
ctx.drawImage(image, 0, 0, asset.width, asset.height);
|
||||
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function smoothState(asset) {
|
||||
const previous = renderStates.get(asset.id) || { ...asset };
|
||||
const factor = 0.15;
|
||||
const next = {
|
||||
x: lerp(previous.x, asset.x, factor),
|
||||
y: lerp(previous.y, asset.y, factor),
|
||||
width: lerp(previous.width, asset.width, factor),
|
||||
height: lerp(previous.height, asset.height, factor),
|
||||
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
|
||||
};
|
||||
renderStates.set(asset.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function smoothAngle(current, target, factor) {
|
||||
const delta = ((target - current + 180) % 360) - 180;
|
||||
return current + delta * factor;
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function ensureImage(asset) {
|
||||
const cached = imageCache.get(asset.id);
|
||||
if (cached && cached.src === asset.url) {
|
||||
@@ -67,10 +95,22 @@ function ensureImage(asset) {
|
||||
return image;
|
||||
}
|
||||
|
||||
function startRenderLoop() {
|
||||
if (animationFrameId) {
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
draw();
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
draw();
|
||||
});
|
||||
|
||||
startRenderLoop();
|
||||
connect();
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
<input id="asset-file" type="file" accept="image/*" />
|
||||
<button onclick="uploadAsset()">Upload</button>
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
<div id="asset-controls" class="panel hidden">
|
||||
<div class="panel-header">
|
||||
<h4 id="selected-asset-name">Selected asset</h4>
|
||||
<small id="selected-asset-meta"></small>
|
||||
</div>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="asset-width" type="number" min="10" step="5" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="asset-height" type="number" min="10" step="5" />
|
||||
</label>
|
||||
<label>
|
||||
Rotation
|
||||
<input id="asset-rotation" type="range" min="-180" max="180" step="1" />
|
||||
<div class="range-value" id="rotation-display">0°</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate -5°</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate +5°</button>
|
||||
<button type="button" onclick="applyTransformFromInputs()">Apply size & rotation</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overlay">
|
||||
|
||||
Reference in New Issue
Block a user