fix: close all 24 open issues (#40–#94)

Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events

Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
  Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons

UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
  and login — white logo pops on dark UI; inline style removed from login.html
This commit is contained in:
Zac Gaetano 2026-05-26 14:10:44 +00:00
parent 602370be26
commit 8e0e94de3d
15 changed files with 383 additions and 70 deletions

View file

@ -0,0 +1,37 @@
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
--
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
-- 2. Add 'completed' to job_status to match the trimWorker status string.
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'proxy';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'import';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
) THEN
ALTER TYPE job_status ADD VALUE 'completed';
END IF;
END $$;
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
-- Backfill: give any existing trim rows a 24h TTL from their creation time
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
WHERE type = 'trim' AND expires_at IS NULL;

View file

@ -0,0 +1,13 @@
-- Migration 017: Partial unique index on live assets (#63)
--
-- Prevents two simultaneous captures from registering the same
-- (project_id, display_name) pair with status='live'. The INSERT in
-- POST /assets will fail with a unique-constraint violation instead of
-- silently overwriting the first capture's metadata.
--
-- Only applies to live rows — archived, ready, error etc. are unaffected,
-- so duplicate names are still allowed across historical recordings.
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
ON assets (project_id, display_name)
WHERE status = 'live';

View file

@ -111,6 +111,8 @@ router.post('/', async (req, res, next) => {
proxyKey,
duration,
capturedAt,
sourceType, // Bug #64: was ignored — now used to set media_type
needsProxy, // Bug #64: was ignored — now controls proxy queue logic
} = req.body;
if (!projectId || !clipName) {
@ -130,25 +132,20 @@ router.post('/', async (req, res, next) => {
[projectId, clipName]
);
// Bug #63: refuse to overwrite a live asset — return 409 with the existing row
if (existing.rows.length > 0) {
return res.status(409).json({
error: 'A live asset with this name already exists',
asset: existing.rows[0],
});
}
let id;
let asset;
if (existing.rows.length > 0) {
id = existing.rows[0].id;
const upd = await pool.query(
`UPDATE assets
SET status = 'processing',
original_s3_key = COALESCE($2, original_s3_key),
proxy_s3_key = COALESCE($3, proxy_s3_key),
duration_ms = COALESCE($4, duration_ms),
bin_id = COALESCE(bin_id, $5),
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, hiresKey || null, proxyKey || null, durationMs, binId || null]
);
asset = upd.rows[0];
} else {
{
id = uuidv4();
// Bug #64: use sourceType to set media_type (default 'video')
const mediaType = (sourceType === 'audio') ? 'audio' : 'video';
const ins = await pool.query(
`INSERT INTO assets (
id, project_id, bin_id,
@ -161,7 +158,7 @@ router.post('/', async (req, res, next) => {
VALUES (
$1, $2, $3,
$4, $4,
'processing', 'video',
'processing', $9,
$5, $6,
$7,
COALESCE($8::timestamptz, NOW()), NOW()
@ -173,6 +170,7 @@ router.post('/', async (req, res, next) => {
hiresKey || null, proxyKey || null,
durationMs,
capturedAt || null,
mediaType,
]
);
asset = ins.rows[0];
@ -180,7 +178,12 @@ router.post('/', async (req, res, next) => {
const thumbnailKey = `thumbnails/${id}.jpg`;
if (proxyKey) {
// Bug #64: when needsProxy is explicitly false and proxyKey is already set,
// skip re-queuing a proxy job and mark the asset ready immediately.
if (needsProxy === false && proxyKey) {
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
asset.status = 'ready';
} else if (proxyKey) {
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
} else if (asset.original_s3_key) {
const generatedProxyKey = `proxies/${id}.mp4`;
@ -193,11 +196,15 @@ router.post('/', async (req, res, next) => {
asset.status = 'ready';
}
res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
res.status(201).json(asset);
} catch (err) {
// Unique constraint violation from the partial index (migration 017) — two
// concurrent captures raced through the SELECT before either INSERT landed.
if (err.code === '23505' && err.constraint === 'idx_assets_live_unique') {
return res.status(409).json({ error: 'A live asset with this name already exists (concurrent capture race)' });
}
next(err);
}
});
// POST /cleanup-live
router.post('/cleanup-live', async (req, res, next) => {
@ -286,6 +293,9 @@ router.post('/:id/copy', async (req, res, next) => {
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const src = r.rows[0];
const newId = uuidv4();
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
// sharing S3 objects with the source. Set status to 'processing' so the copy
// gets its own proxy generated. Re-queue proxy generation below if source exists.
const ins = await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name,
@ -293,18 +303,31 @@ router.post('/:id/copy', async (req, res, next) => {
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
created_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW(),NOW()
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
) RETURNING *`,
[
newId, projectId || src.project_id,
binId === undefined ? src.bin_id : (binId || null),
src.filename, src.display_name, src.status, src.media_type,
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
src.filename, src.display_name, 'processing', src.media_type,
src.original_s3_key,
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
src.file_size, src.tags, src.notes,
]
);
res.status(201).json(ins.rows[0]);
const copy = ins.rows[0];
// Re-queue proxy generation from original_s3_key so the copy gets its own proxy
if (copy.original_s3_key) {
const newProxyKey = `proxies/${newId}.mp4`;
await proxyQueue.add('generate', {
assetId: newId, inputKey: copy.original_s3_key, outputKey: newProxyKey,
});
console.log(`[assets] queued proxy for copy ${newId} from ${newProxyKey}`);
} else {
// No source to transcode from — mark ready immediately
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [newId]);
copy.status = 'ready';
}
res.status(201).json(copy);
} catch (err) { next(err); }
});
@ -312,16 +335,31 @@ router.post('/:id/copy', async (req, res, next) => {
router.post('/:id/mark-empty', async (req, res, next) => {
try {
const { id } = req.params;
const result = await pool.query(
// Bug #66: first check the asset exists and what status it is in
const check = await pool.query(`SELECT id, status FROM assets WHERE id = $1`, [id]);
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const current = check.rows[0].status;
// Already terminal — nothing to do, return 200 with skipped flag
if (current === 'error' || current === 'ready') {
return res.status(200).json({ id, skipped: true });
}
// Allow update for 'live' or 'processing' (race condition on shutdown)
if (current === 'live' || current === 'processing') {
await pool.query(
`UPDATE assets
SET status = 'error',
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
updated_at = NOW()
WHERE id = $1 AND status = 'live' RETURNING id`,
WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
res.json({ id });
return res.json({ id });
}
// Any other status (e.g. 'archived') is incompatible
return res.status(409).json({
error: `Cannot mark-empty an asset with status '${current}'`,
status: current,
});
} catch (err) { next(err); }
});
@ -365,6 +403,37 @@ router.post('/backfill-proxies', async (_req, res, next) => {
} catch (err) { next(err); }
});
// POST /:id/reprocess?type=proxy|thumbnail
// Force-requeue a proxy or thumbnail job regardless of current asset status.
// Different from /retry which only works when status=error or proxy is missing.
router.post('/:id/reprocess', async (req, res, next) => {
try {
const { id } = req.params;
const type = req.query.type || 'proxy';
if (!['proxy', 'thumbnail'].includes(type)) {
return res.status(400).json({ error: 'type must be "proxy" or "thumbnail"' });
}
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = r.rows[0];
if (!asset.original_s3_key) {
return res.status(400).json({ error: 'Asset has no source file to reprocess' });
}
if (type === 'proxy') {
const proxyKey = `proxies/${id}.mp4`;
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
await pool.query(`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`, [id]);
return res.json({ queued: 'proxy', assetId: id });
}
if (type === 'thumbnail') {
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
const thumbnailKey = `thumbnails/${id}.jpg`;
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
return res.json({ queued: 'thumbnail', assetId: id });
}
} catch (err) { next(err); }
});
// POST /:id/retry
router.post('/:id/retry', async (req, res, next) => {
try {
@ -393,6 +462,20 @@ router.delete('/:id', async (req, res, next) => {
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = assetResult.rows[0];
// Remove any pending/waiting BullMQ jobs for this asset before deleting
// the row — prevents workers from receiving jobs for a non-existent asset.
for (const queue of [proxyQueue, thumbnailQueue]) {
try {
const waiting = await queue.getJobs(['waiting', 'delayed', 'prioritized']);
for (const job of waiting) {
if (job.data?.assetId === id) await job.remove();
}
} catch (e) {
console.warn(`[assets] BullMQ cleanup failed for asset ${id}:`, e.message);
}
}
const s3Errors = [];
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
if (!key) continue;
@ -420,8 +503,13 @@ router.get('/:id/stream', async (req, res, next) => {
const a = r.rows[0];
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
// Fall back to original for any video file so uploaded/YouTube clips
// show a filmstrip even before the proxy worker finishes (#58)
const orig = a.original_s3_key;
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
if (orig && VIDEO_EXTS.some(ext => orig.toLowerCase().endsWith(ext))) {
return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4', source: 'original' });
}
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
} catch (err) { next(err); }
});
@ -458,7 +546,9 @@ router.get('/:id/video', async (req, res, next) => {
const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const a = r.rows[0];
const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null);
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext));
const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null);
if (!key) return res.status(404).json({ error: 'No browser-playable source' });
const params = { Bucket: getS3Bucket(), Key: key };
const rangeHeader = req.headers.range;
@ -518,7 +608,10 @@ router.post('/batch-trim', async (req, res, next) => {
}
const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(`INSERT INTO jobs (id, type, status, payload) VALUES ($1,$2,$3,$4)`, [jobId, 'trim', 'queued', JSON.stringify({ clips })]);
await pool.query(
`INSERT INTO jobs (id, type, status, payload, expires_at) VALUES ($1,$2,$3,$4,$5)`,
[jobId, 'trim', 'queued', JSON.stringify({ clips }), expiresAt]
);
const clipResults = [];
for (const c of clips) {
const clipInstanceId = uuidv4();
@ -537,6 +630,13 @@ router.get('/trim-status/:jobId', async (req, res, next) => {
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
const job = jobResult.rows[0];
// Auto-expire: delete stale jobs rows (and their temp_segments) past TTL
if (job.expires_at && new Date(job.expires_at) < new Date()) {
await pool.query('DELETE FROM temp_segments WHERE job_id = $1', [jobId]);
await pool.query('DELETE FROM jobs WHERE id = $1', [jobId]);
return res.status(404).json({ error: 'Trim job expired' });
}
const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]);
const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at }));
res.json({ jobId, status: job.status, clips });

View file

@ -140,11 +140,52 @@ router.get('/events', async (req, res) => {
await push();
});
// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
// Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
// Only returns non-expired rows.
async function getDbJobs() {
try {
const result = await pool.query(
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
ts.asset_id
FROM jobs j
LEFT JOIN temp_segments ts ON ts.job_id = j.id
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
ORDER BY j.created_at DESC
LIMIT 200`
);
// Dedupe — multiple temp_segments per job, take first asset_id found
const seen = new Map();
for (const row of result.rows) {
if (!seen.has(row.id)) {
seen.set(row.id, {
id: `trim:${row.id}`,
type: row.type,
status: row.status === 'completed' ? 'completed' : row.status,
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
asset_id: row.asset_id || null,
asset_name: null,
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
started_at: null,
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
error: null,
metadata: row.payload || {},
});
}
}
return [...seen.values()];
} catch {
return [];
}
}
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
router.get('/', async (req, res, next) => {
try {
const { type, status, asset_id } = req.query;
let jobs = await getAllBullMQJobs();
const dbJobs = await getDbJobs();
jobs = jobs.concat(dbJobs);
await attachAssetNames(jobs);
if (type) jobs = jobs.filter(j => j.type === type);

View file

@ -3,6 +3,7 @@ import http from 'http';
import net from 'net';
import dgram from 'dgram';
import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';
@ -35,6 +36,10 @@ function dockerApi(method, path, body = null) {
});
});
req.on('error', reject);
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => {
req.destroy(new Error('Docker API timeout after 10s'));
});
if (body) req.write(JSON.stringify(body));
req.end();
});
@ -297,7 +302,7 @@ router.post('/:id/start', async (req, res, next) => {
}
const s3Endpoint = process.env.S3_ENDPOINT;
const s3Bucket = process.env.S3_BUCKET;
const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61)
const s3AccessKey = process.env.S3_ACCESS_KEY;
const s3SecretKey = process.env.S3_SECRET_KEY;
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';

View file

@ -15,7 +15,8 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
const router = express.Router();
const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage });
// 500 MB file size cap on multipart parts to prevent OOM (#74)
const upload = multer({ storage: memoryStorage, limits: { fileSize: 500 * 1024 * 1024 } });
const parseRedisUrl = (url) => {
const parsed = new URL(url);
@ -91,6 +92,20 @@ function mediaTypeFromMime(mime = '') {
return 'document';
}
// GET /api/v1/upload - List in-progress uploads (#68)
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
FROM assets
WHERE status = 'ingesting'
ORDER BY created_at DESC
LIMIT 50`
);
res.json(result.rows);
} catch (err) { next(err); }
});
// POST /api/v1/upload/init - Initialize a multipart upload
router.post('/init', async (req, res, next) => {
try {
@ -212,7 +227,10 @@ router.post('/complete', async (req, res, next) => {
outputKey: `proxies/${assetId}.mp4`,
});
syncToAmpp(asset.id, asset.project_id, asset.bin_id);
// Await AMPP sync to catch errors; failures are logged but non-fatal
await syncToAmpp(asset.id, asset.project_id, asset.bin_id).catch(err => {
console.error(`AMPP sync failed for asset ${asset.id}:`, err);
});
res.json(asset);
} catch (err) {
@ -289,7 +307,10 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
outputKey: `proxies/${assetId}.mp4`,
});
syncToAmpp(assetId, projectId, binId || null);
// Await AMPP sync to catch errors; failures are logged but non-fatal
await syncToAmpp(assetId, projectId, binId || null).catch(err => {
console.error(`AMPP sync failed for asset ${assetId}:`, err);
});
res.json(asset);
} catch (err) {

View file

@ -34,14 +34,21 @@ let _client = buildClient(_cfg);
// rebuildS3Client() replaces the underlying instance.
export const s3Client = new Proxy(
{},
{ get(_target, prop) { return Reflect.get(_client, prop, _client); } }
{
get(_target, prop) {
// Bug #61: bind method calls to the current _client so `this` is correct
// even after rebuildS3Client() replaces the underlying instance.
const val = Reflect.get(_client, prop, _client);
return typeof val === 'function' ? val.bind(_client) : val;
},
}
);
// ── Bucket getter — always returns the current bucket name ───────────────────
export function getS3Bucket() { return _cfg.bucket; }
// Legacy named export kept for any code that captured it at import time.
// Prefer getS3Bucket() in new code.
// @deprecated — import binding captures the value at module load time and
// will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead.
export let S3_BUCKET = _cfg.bucket;
// ── Rebuild — swap in new config at runtime (called by settings API) ─────────

View file

@ -103,6 +103,26 @@ async function tick() {
console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
}
}
// 4) Mark stale live assets as 'error' (#66).
// If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await pool.query(
`UPDATE assets
SET status = 'error',
error_message = 'Recording timed out — capture ended unexpectedly',
updated_at = NOW()
WHERE status = 'live'
AND created_at < NOW() - ($1 || ' minutes')::INTERVAL
RETURNING id, display_name`,
[LIVE_TIMEOUT_MINUTES]
);
if (staleResult.rows.length > 0) {
for (const row of staleResult.rows) {
console.warn(`[scheduler] marked stale live asset as error: ${row.id} (${row.display_name})`);
}
}
} catch (err) {
console.error('[scheduler] tick error:', err);
} finally {

View file

@ -59,6 +59,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Preserve Content-Type so multer receives the full multipart boundary (#74)
proxy_set_header Content-Type $content_type;
proxy_buffering off;
proxy_request_buffering off;
proxy_connect_timeout 300;

View file

@ -111,6 +111,9 @@
justify-content: center;
font: 700 15px/1 var(--font);
}
.brand-icon img {
filter: brightness(0) invert(1);
}
.brand-name {
font: 600 15px/1.2 var(--font);
letter-spacing: -0.01em;
@ -199,7 +202,7 @@
<section class="panel">
<div class="brand">
<div class="brand-icon">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;filter:brightness(0) invert(1);">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;">
</div>
<div>
<div class="brand-name">Dragonflight</div>

View file

@ -34,6 +34,7 @@ function AssetDetail({ asset, onClose }) {
const [streamLoading, setStreamLoading] = React.useState(false);
const [videoDuration, setVideoDuration] = React.useState(0);
const [retrying, setRetrying] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null
const [filmFrames, setFilmFrames] = React.useState([]);
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
const videoRef = React.useRef(null);
@ -245,6 +246,17 @@ function AssetDetail({ asset, onClose }) {
.finally(function() { setRetrying(false); });
};
const reprocessJob = function(type) {
if (reprocessing) return;
setReprocessing(type);
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
.then(function() {
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
})
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
.finally(function() { setReprocessing(null); });
};
// Map a /assets/:id/comments row into the legacy shape the consumer
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
function _normalizeComment(row) {
@ -355,6 +367,12 @@ function AssetDetail({ asset, onClose }) {
{menuOpen && (
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
<button onClick={function() { setMenuOpen(false); reprocessJob('proxy'); }} disabled={!!reprocessing}>
<Icon name="jobs" size={11} />{reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate proxy'}
</button>
<button onClick={function() { setMenuOpen(false); reprocessJob('thumbnail'); }} disabled={!!reprocessing}>
<Icon name="jobs" size={11} />{reprocessing === 'thumbnail' ? 'Queuing…' : 'Re-generate thumbnail'}
</button>
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
</div>
)}

View file

@ -502,7 +502,12 @@ function Recorders({ navigate, onNew }) {
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
.catch(err => {
// apiFetch already redirects on 401 don't log noise, interval
// will be cleared automatically when the component unmounts on redirect (#55)
if (err && err.message && err.message.includes('Unauthenticated')) return;
console.warn('[recorders] poll error:', err?.message);
});
}, []);
React.useEffect(() => {

View file

@ -162,8 +162,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
React.useEffect(function() {
if (!projectCtx) return;
var close = function() { setProjectCtx(null); };
// #49: add contextmenu + scroll dismiss so right-clicking another project
// or scrolling away doesn't leave the menu orphaned
window.addEventListener('click', close);
return function() { window.removeEventListener('click', close); };
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return function() {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [projectCtx]);
var openProjectCtx = function(p, e) {
@ -539,13 +547,15 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
}}
/>
)}
</div>
{/* Status badges and duration inside the relative wrapper so
position:absolute is anchored to the thumbnail, not the card (#52) */}
<div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
</div>
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
</div>
<div className="meta">
<div className="name">{asset.name}</div>
<div className="sub">

View file

@ -117,7 +117,16 @@ function Projects({ onOpenProject, navigate }) {
</div>
) : view === 'grid' ? (
<div className="projects-grid">
{filtered.map(p => <ProjectCard key={p.id} project={p} assets={ASSETS} onOpen={() => onOpenProject(p)} />)}
{filtered.map(p => (
<ProjectCard
key={p.id}
project={p}
assets={ASSETS}
onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)}
onDelete={() => deleteProject(p)}
/>
))}
</div>
) : (
<div className="panel">
@ -197,7 +206,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
);
}
function ProjectCard({ project, assets, onOpen }) {
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4);
@ -210,8 +219,29 @@ function ProjectCard({ project, assets, onOpen }) {
const inFlightPct = (inFlight / total) * 100;
const errPct = (errored / total) * 100;
// #50: context menu state for grid card
const [ctx, setCtx] = React.useState(null);
React.useEffect(() => {
if (!ctx) return;
const close = () => setCtx(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctx]);
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY });
};
return (
<div className="project-card" onClick={onOpen}>
<div className="project-card" onClick={onOpen} onContextMenu={handleContextMenu}>
<div className="project-thumb-grid">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="project-thumb-cell">
@ -241,6 +271,13 @@ function ProjectCard({ project, assets, onOpen }) {
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
)}
</div>
{ctx && (
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div>
)}
</div>
);
}

View file

@ -245,19 +245,14 @@
height: 32px;
flex-shrink: 0;
object-fit: contain;
/* The PNG has a light-gray background. Invert it so the black
dragon becomes white-ish, then multiply against the sidebar
so the (now-bright) gray background falls back out. Simpler
route: a subtle drop-shadow + mix-blend. */
mix-blend-mode: screen;
filter: drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
/* Convert the dark logo to white so it pops on the dark sidebar.
brightness(0) collapses everything to black, invert(1) flips to white.
Works on both the original dark PNG and any transparent white PNG. */
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
}
.sidebar-header:hover .brand-logo {
filter: drop-shadow(0 0 10px rgba(91, 124, 250, 0.45));
filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(91, 124, 250, 0.45));
}
/* If you ever drop a transparent-PNG version into img/dragon-logo.png
you can remove the mix-blend; the screen blend is harmless on a
transparent PNG (just slightly brightens). */
/* ============================================================
Launcher home full-bleed landing page with the logo as hero
@ -297,10 +292,9 @@
width: 180px;
height: 180px;
object-fit: contain;
/* Drop the gray PNG background see .brand-logo comment for the
trick. Soft accent halo so the silhouette has presence. */
mix-blend-mode: screen;
/* Convert to white — same approach as .brand-logo. */
filter:
brightness(0) invert(1)
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18));
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;