2026-05-18 20:10:25 -04:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<title>Editor — Z-AMPP</title>
|
|
|
|
|
|
|
|
|
|
|
|
<link rel="stylesheet" href="css/common.css">
|
2026-05-18 20:10:25 -04:00
|
|
|
|
<style>
|
|
|
|
|
|
/* ── Editor layout ─────────────────────────────────────────── */
|
|
|
|
|
|
.editor-shell {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 2-column × 2-row grid */
|
|
|
|
|
|
.editor-body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 300px 1fr;
|
|
|
|
|
|
grid-template-rows: 40vh 1fr;
|
|
|
|
|
|
grid-template-areas:
|
|
|
|
|
|
"source program"
|
|
|
|
|
|
"media timeline-panel";
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Monitor shared styles ─────────────────────────────────── */
|
|
|
|
|
|
.monitor {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
border-right: 1px solid var(--border);
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: var(--bg-base);
|
|
|
|
|
|
}
|
|
|
|
|
|
.monitor-source { grid-area: source; }
|
|
|
|
|
|
.monitor-program { grid-area: program; border-right: none; }
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-video-wrap {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.monitor-video-wrap video {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
max-height: 100%;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
background: var(--bg-panel);
|
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-tc {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
font-family: 'Courier New', monospace;
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
letter-spacing: .04em;
|
|
|
|
|
|
min-width: 96px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-scrub {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 3px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
accent-color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-inout {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--sp-1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Media panel ───────────────────────────────────────────── */
|
|
|
|
|
|
.media-panel {
|
|
|
|
|
|
grid-area: media;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
border-right: 1px solid var(--border);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: var(--bg-panel);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-panel-header {
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-panel-title {
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: .08em;
|
|
|
|
|
|
color: var(--text-tertiary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-asset-list {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: var(--sp-1) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-asset-item {
|
|
|
|
|
|
display: flex;
|
2026-05-18 23:37:56 -04:00
|
|
|
|
align-items: flex-start;
|
2026-05-18 20:10:25 -04:00
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background var(--t-fast), color var(--t-fast);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
2026-05-18 23:37:56 -04:00
|
|
|
|
.media-asset-item svg { flex-shrink: 0; margin-top: 2px; }
|
2026-05-18 20:10:25 -04:00
|
|
|
|
.media-asset-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
|
|
|
|
.media-asset-item.active { background: var(--accent-subtle); color: var(--accent); }
|
2026-05-18 23:37:56 -04:00
|
|
|
|
|
|
|
|
|
|
.media-asset-info {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-asset-name {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.media-asset-meta {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--text-tertiary);
|
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
.media-asset-item.active .media-asset-meta { color: var(--accent); opacity: 0.7; }
|
2026-05-18 20:10:25 -04:00
|
|
|
|
|
|
|
|
|
|
/* ── Timeline panel ────────────────────────────────────────── */
|
|
|
|
|
|
.timeline-panel {
|
|
|
|
|
|
grid-area: timeline-panel;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.timeline-toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
background: var(--bg-panel);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tl-tool-btn {
|
|
|
|
|
|
height: 26px;
|
|
|
|
|
|
padding: 0 var(--sp-2);
|
|
|
|
|
|
background: var(--bg-surface);
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: var(--r-sm);
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
.tl-tool-btn:hover { border-color: var(--accent-border); color: var(--text-primary); }
|
|
|
|
|
|
.tl-tool-btn.active { background: var(--accent-subtle); border-color: var(--accent-border); color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
.tl-sep { width: 1px; height: 18px; background: var(--border); }
|
|
|
|
|
|
|
|
|
|
|
|
.tl-save-status {
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--text-tertiary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 22:47:33 -04:00
|
|
|
|
.timeline-container.tl-drag-over {
|
|
|
|
|
|
outline: 2px dashed var(--accent, #0fa3ff);
|
|
|
|
|
|
background: rgba(15,163,255,0.06);
|
|
|
|
|
|
}
|
2026-05-18 20:10:25 -04:00
|
|
|
|
.timeline-container {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Topbar: sequence info ─────────────────────────────────── */
|
|
|
|
|
|
.topbar-seq-name {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
padding: 0 var(--sp-2);
|
|
|
|
|
|
border-radius: var(--r-sm);
|
|
|
|
|
|
transition: background var(--t-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
.topbar-seq-name:hover { background: var(--bg-hover); }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="shell">
|
|
|
|
|
|
<!-- Sidebar -->
|
|
|
|
|
|
<nav class="sidebar" aria-label="Main navigation">
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<div class="sidebar-brand">
|
|
|
|
|
|
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
|
|
|
|
|
|
<span class="sidebar-brand-name">Z-AMPP</span>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
</div>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<nav class="sidebar-nav">
|
|
|
|
|
|
<a href="home.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
|
|
|
|
|
|
Home
|
|
|
|
|
|
</a>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
<a href="index.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
|
|
|
|
|
Library
|
|
|
|
|
|
</a>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<a href="projects.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
|
|
|
|
|
|
Projects
|
2026-05-18 20:10:25 -04:00
|
|
|
|
</a>
|
|
|
|
|
|
<a href="upload.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
|
|
|
|
|
Ingest
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a href="recorders.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
|
|
|
|
|
Recorders
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a href="capture.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
|
|
|
|
|
Capture
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a href="jobs.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
|
|
|
|
|
Jobs
|
|
|
|
|
|
</a>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<a href="editor.html" class="nav-item active">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
|
|
|
|
|
Editor
|
2026-05-18 20:10:25 -04:00
|
|
|
|
</a>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<div class="sidebar-section-label">Admin</div>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
<a href="users.html" class="nav-item">
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
Users
|
|
|
|
|
|
</a>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
<a href="tokens.html" class="nav-item">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
|
|
|
|
|
Tokens
|
|
|
|
|
|
</a>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
</nav>
|
2026-05-18 23:11:53 -04:00
|
|
|
|
|
|
|
|
|
|
<div class="sidebar-footer">
|
2026-05-18 20:10:25 -04:00
|
|
|
|
<div class="sidebar-user">
|
|
|
|
|
|
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
|
|
|
|
|
<div class="sidebar-user-info">
|
|
|
|
|
|
<div class="sidebar-user-name" id="userName">—</div>
|
|
|
|
|
|
<div class="sidebar-user-role" id="userRole"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Main area -->
|
|
|
|
|
|
<div class="main">
|
|
|
|
|
|
<!-- Topbar -->
|
|
|
|
|
|
<header class="topbar">
|
|
|
|
|
|
<div class="topbar-left">
|
|
|
|
|
|
<span class="page-title">Editor</span>
|
|
|
|
|
|
<span class="topbar-sep">/</span>
|
|
|
|
|
|
<select id="seqSelect" class="project-select" style="min-width:160px;" aria-label="Select sequence"></select>
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm" id="newSeqBtn" title="New sequence">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 2v12M2 8h12"/></svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="topbar-right">
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm" id="exportEdlBtn">Export EDL</button>
|
|
|
|
|
|
<button class="btn btn-primary btn-sm" id="saveBtn">Save</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Editor body -->
|
|
|
|
|
|
<div class="editor-shell">
|
|
|
|
|
|
<div class="editor-body">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Source Monitor -->
|
|
|
|
|
|
<div class="monitor monitor-source">
|
|
|
|
|
|
<div class="monitor-video-wrap">
|
|
|
|
|
|
<video id="srcVideo" preload="metadata"></video>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="monitor-bar">
|
|
|
|
|
|
<span class="monitor-tc" id="srcTC">00:00:00;00</span>
|
|
|
|
|
|
<input type="range" class="monitor-scrub" id="srcScrub" min="0" max="1000" value="0" step="1">
|
|
|
|
|
|
<div class="monitor-inout">
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcInBtn" title="Mark In (I)">In</button>
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcOutBtn" title="Mark Out (O)">Out</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn btn-primary btn-sm" id="insertBtn" title="Insert at playhead">Insert</button>
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm" id="overwriteBtn" title="Overwrite at playhead">OW</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Program Monitor -->
|
|
|
|
|
|
<div class="monitor monitor-program">
|
|
|
|
|
|
<div class="monitor-video-wrap">
|
|
|
|
|
|
<video id="pgmVideo" preload="auto"></video>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="monitor-bar">
|
|
|
|
|
|
<button class="tl-tool-btn" id="pgmPlayBtn" style="min-width:32px;">▶</button>
|
|
|
|
|
|
<span class="monitor-tc" id="pgmTC">00:00:00;00</span>
|
|
|
|
|
|
<input type="range" class="monitor-scrub" id="pgmScrub" min="0" max="1000" value="0" step="1">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Media Panel -->
|
|
|
|
|
|
<div class="media-panel">
|
|
|
|
|
|
<div class="media-panel-header">
|
|
|
|
|
|
<span class="media-panel-title">Media</span>
|
|
|
|
|
|
<select id="mediaBinSel" style="font-size:var(--text-xs);height:22px;" aria-label="Filter by bin">
|
|
|
|
|
|
<option value="">All assets</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="media-asset-list" id="mediaAssetList"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Timeline Panel -->
|
|
|
|
|
|
<div class="timeline-panel">
|
|
|
|
|
|
<div class="timeline-toolbar">
|
|
|
|
|
|
<button class="tl-tool-btn active" id="toolSelect" title="Select (V)">V</button>
|
|
|
|
|
|
<button class="tl-tool-btn" id="toolRazor" title="Razor (C)">C</button>
|
|
|
|
|
|
<button class="tl-tool-btn" id="toolHand" title="Hand (H)">H</button>
|
|
|
|
|
|
<div class="tl-sep"></div>
|
|
|
|
|
|
<button class="tl-tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">↩</button>
|
|
|
|
|
|
<button class="tl-tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">↪</button>
|
|
|
|
|
|
<span class="tl-save-status" id="saveStatus"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="timeline-container" id="timelineContainer"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div><!-- .editor-body -->
|
|
|
|
|
|
</div><!-- .editor-shell -->
|
|
|
|
|
|
</div><!-- .main -->
|
|
|
|
|
|
</div><!-- .shell -->
|
|
|
|
|
|
|
|
|
|
|
|
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- New sequence dialog -->
|
|
|
|
|
|
<div class="slide-overlay" id="seqOverlay"></div>
|
|
|
|
|
|
<div class="slide-panel" id="seqPanel">
|
|
|
|
|
|
<div class="slide-panel-header">
|
|
|
|
|
|
<span class="slide-panel-title">New sequence</span>
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm" id="closeSeqPanel" style="padding:0;width:28px;height:28px;">
|
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="slide-panel-body">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label" for="newSeqName">Sequence name</label>
|
|
|
|
|
|
<input type="text" id="newSeqName" placeholder="e.g. Rough Cut v1">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="slide-panel-footer">
|
|
|
|
|
|
<button class="btn btn-ghost" id="cancelSeqBtn">Cancel</button>
|
|
|
|
|
|
<button class="btn btn-primary" id="saveSeqBtn">Create</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-18 23:53:38 -04:00
|
|
|
|
<script src="js/api.js?v=6"></script>
|
2026-05-18 20:10:25 -04:00
|
|
|
|
<script src="js/timecode.js"></script>
|
|
|
|
|
|
<script src="js/timeline.js"></script>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// APP STATE
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
projectId: null,
|
|
|
|
|
|
sequences: [],
|
|
|
|
|
|
seq: null,
|
|
|
|
|
|
bins: [],
|
|
|
|
|
|
assets: [],
|
|
|
|
|
|
sourceAsset: null,
|
|
|
|
|
|
srcIn: null,
|
|
|
|
|
|
srcOut: null,
|
|
|
|
|
|
pgmPlaying: false,
|
|
|
|
|
|
pgmClipIdx: -1,
|
|
|
|
|
|
pgmClips: [],
|
|
|
|
|
|
streamCache: {},
|
|
|
|
|
|
saveTimer: null,
|
|
|
|
|
|
history: [],
|
|
|
|
|
|
historyIdx: -1,
|
|
|
|
|
|
isDirty: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// INIT
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
|
|
state.projectId = params.get('project');
|
|
|
|
|
|
const openAsset = params.get('asset');
|
|
|
|
|
|
|
|
|
|
|
|
if (!state.projectId) {
|
|
|
|
|
|
const pr = await getProjects();
|
|
|
|
|
|
if (pr.success && pr.data.length) state.projectId = pr.data[0].id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!state.projectId) { toast('No project selected', 'Go to Library first', 'warning'); return; }
|
|
|
|
|
|
|
|
|
|
|
|
setupToolbar();
|
|
|
|
|
|
setupSourceMonitor();
|
|
|
|
|
|
setupProgramMonitor();
|
|
|
|
|
|
setupKeyboard();
|
|
|
|
|
|
setupNewSeqPanel();
|
|
|
|
|
|
|
|
|
|
|
|
Timeline.init(document.getElementById('timelineContainer'), {
|
|
|
|
|
|
fps: 59.94,
|
|
|
|
|
|
scale: 100,
|
|
|
|
|
|
onClipsChanged: onClipsChanged,
|
|
|
|
|
|
onPlayheadMoved: onPlayheadMoved,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 22:47:33 -04:00
|
|
|
|
(function setupTimelineDrop() {
|
|
|
|
|
|
var tlc = document.getElementById('timelineContainer');
|
|
|
|
|
|
tlc.addEventListener('dragover', function(e) {
|
|
|
|
|
|
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
|
|
|
|
|
|
tlc.classList.add('tl-drag-over');
|
|
|
|
|
|
});
|
|
|
|
|
|
tlc.addEventListener('dragleave', function(e) {
|
|
|
|
|
|
if (!tlc.contains(e.relatedTarget)) tlc.classList.remove('tl-drag-over');
|
|
|
|
|
|
});
|
|
|
|
|
|
tlc.addEventListener('drop', async function(e) {
|
|
|
|
|
|
e.preventDefault(); tlc.classList.remove('tl-drag-over');
|
|
|
|
|
|
var raw = e.dataTransfer.getData('application/x-zampp-asset');
|
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
|
var asset; try { asset = JSON.parse(raw); } catch(err) { return; }
|
|
|
|
|
|
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
|
|
|
|
|
|
var r = await getAssetStreamUrl(asset.id);
|
|
|
|
|
|
if (r.success && r.data && r.data.url) state.streamCache[asset.id] = r.data.url;
|
|
|
|
|
|
Timeline.addClip(Object.assign({}, asset, { streamUrl: state.streamCache[asset.id]||null }), 0, (asset.duration_ms||10000)/1000, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-05-18 20:10:25 -04:00
|
|
|
|
await loadProject();
|
|
|
|
|
|
await loadSequences();
|
|
|
|
|
|
await loadMediaAssets();
|
|
|
|
|
|
|
|
|
|
|
|
if (openAsset) {
|
|
|
|
|
|
const asset = state.assets.find(a => a.id === openAsset);
|
|
|
|
|
|
if (asset) loadSourceAsset(asset);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// PROJECT + SEQUENCES
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async function loadProject() {
|
|
|
|
|
|
const r = await getBins(state.projectId);
|
|
|
|
|
|
state.bins = r.success ? r.data : [];
|
|
|
|
|
|
const sel = document.getElementById('mediaBinSel');
|
|
|
|
|
|
sel.innerHTML = '<option value="">All assets</option>' +
|
|
|
|
|
|
state.bins.map(b => '<option value="' + b.id + '">' + esc(b.name) + '</option>').join('');
|
|
|
|
|
|
sel.onchange = () => loadMediaAssets();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSequences() {
|
|
|
|
|
|
const r = await getSequences(state.projectId);
|
|
|
|
|
|
state.sequences = r.success ? r.data : [];
|
|
|
|
|
|
renderSeqSelect();
|
|
|
|
|
|
if (state.sequences.length) await openSequence(state.sequences[0].id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderSeqSelect() {
|
|
|
|
|
|
const sel = document.getElementById('seqSelect');
|
|
|
|
|
|
if (!state.sequences.length) {
|
|
|
|
|
|
sel.innerHTML = '<option value="">No sequences — create one</option>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
sel.innerHTML = state.sequences.map(s =>
|
|
|
|
|
|
'<option value="' + s.id + '">' + esc(s.name) + '</option>'
|
|
|
|
|
|
).join('');
|
|
|
|
|
|
sel.onchange = () => openSequence(sel.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openSequence(id) {
|
|
|
|
|
|
const r = await getSequence(id);
|
|
|
|
|
|
if (!r.success) { toast('Failed to load sequence', r.error, 'error'); return; }
|
|
|
|
|
|
state.seq = r.data;
|
|
|
|
|
|
state.history = [cloneClips(state.seq.clips)];
|
|
|
|
|
|
state.historyIdx = 0;
|
|
|
|
|
|
document.getElementById('seqSelect').value = id;
|
|
|
|
|
|
Timeline.render(state.seq.clips, { fps: 59.94 });
|
|
|
|
|
|
updateProgramScrub();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// MEDIA PANEL
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async function loadMediaAssets() {
|
|
|
|
|
|
const filters = { project_id: state.projectId };
|
|
|
|
|
|
const binId = document.getElementById('mediaBinSel').value;
|
|
|
|
|
|
if (binId) filters.bin_id = binId;
|
|
|
|
|
|
const r = await getAssets(filters);
|
|
|
|
|
|
state.assets = r.success ? r.data : [];
|
|
|
|
|
|
renderMediaList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 23:37:56 -04:00
|
|
|
|
function fmtMs(ms) {
|
|
|
|
|
|
if (!ms || ms <= 0) return null;
|
|
|
|
|
|
const s = Math.floor(ms / 1000);
|
|
|
|
|
|
const h = Math.floor(s / 3600);
|
|
|
|
|
|
const m = Math.floor((s % 3600) / 60);
|
|
|
|
|
|
const sc = s % 60;
|
|
|
|
|
|
if (h > 0) return [h, m, sc].map(v => String(v).padStart(2,'0')).join(':');
|
|
|
|
|
|
return [m, sc].map(v => String(v).padStart(2,'0')).join(':');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 20:10:25 -04:00
|
|
|
|
function renderMediaList() {
|
|
|
|
|
|
const list = document.getElementById('mediaAssetList');
|
|
|
|
|
|
list.innerHTML = '';
|
|
|
|
|
|
state.assets.forEach(function(asset) {
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'media-asset-item';
|
2026-05-18 23:37:56 -04:00
|
|
|
|
|
|
|
|
|
|
const metaParts = [
|
|
|
|
|
|
asset.resolution || null,
|
|
|
|
|
|
asset.codec || null,
|
|
|
|
|
|
asset.fps ? asset.fps + ' fps' : null,
|
|
|
|
|
|
fmtMs(asset.duration_ms),
|
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
const metaStr = metaParts.join(' · ');
|
|
|
|
|
|
|
2026-05-18 20:10:25 -04:00
|
|
|
|
el.innerHTML =
|
|
|
|
|
|
'<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" width="12" height="12">' +
|
|
|
|
|
|
'<rect x="1" y="3" width="8" height="8" rx="1"/>' +
|
|
|
|
|
|
'<path d="M9 6l4-2v6l-4-2"/>' +
|
|
|
|
|
|
'</svg>' +
|
2026-05-18 23:37:56 -04:00
|
|
|
|
'<div class="media-asset-info">' +
|
|
|
|
|
|
'<span class="media-asset-name" title="' + esc(asset.display_name || asset.filename) + '">' +
|
|
|
|
|
|
esc(asset.display_name || asset.filename) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(metaStr ? '<span class="media-asset-meta">' + esc(metaStr) + '</span>' : '') +
|
|
|
|
|
|
'</div>';
|
|
|
|
|
|
|
2026-05-19 22:47:33 -04:00
|
|
|
|
el.draggable = true;
|
|
|
|
|
|
el.ondragstart = function(e) {
|
|
|
|
|
|
e.dataTransfer.setData('application/x-zampp-asset', JSON.stringify({ id: asset.id, display_name: asset.display_name || asset.filename, duration_ms: asset.duration_ms }));
|
|
|
|
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
|
|
|
|
};
|
2026-05-18 20:10:25 -04:00
|
|
|
|
el.ondblclick = function() { loadSourceAsset(asset); };
|
|
|
|
|
|
el.onclick = function() {
|
|
|
|
|
|
list.querySelectorAll('.media-asset-item').forEach(function(e) { e.classList.remove('active'); });
|
|
|
|
|
|
el.classList.add('active');
|
|
|
|
|
|
};
|
|
|
|
|
|
list.appendChild(el);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// SOURCE MONITOR
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function setupSourceMonitor() {
|
|
|
|
|
|
const vid = document.getElementById('srcVideo');
|
|
|
|
|
|
const scrub = document.getElementById('srcScrub');
|
|
|
|
|
|
|
|
|
|
|
|
vid.addEventListener('timeupdate', function() {
|
|
|
|
|
|
document.getElementById('srcTC').textContent =
|
|
|
|
|
|
TC.framesToTC(TC.secondsToFrames(vid.currentTime));
|
|
|
|
|
|
if (!vid.duration) return;
|
|
|
|
|
|
scrub.value = Math.round((vid.currentTime / vid.duration) * 1000);
|
|
|
|
|
|
updateSrcInOutMarkers();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
scrub.addEventListener('input', function() {
|
|
|
|
|
|
if (!vid.duration) return;
|
|
|
|
|
|
vid.currentTime = (scrub.value / 1000) * vid.duration;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('srcInBtn').onclick = markSrcIn;
|
|
|
|
|
|
document.getElementById('srcOutBtn').onclick = markSrcOut;
|
|
|
|
|
|
document.getElementById('insertBtn').onclick = doInsert;
|
|
|
|
|
|
document.getElementById('overwriteBtn').onclick = doOverwrite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSourceAsset(asset) {
|
|
|
|
|
|
state.sourceAsset = asset;
|
|
|
|
|
|
state.srcIn = null;
|
|
|
|
|
|
state.srcOut = null;
|
|
|
|
|
|
|
|
|
|
|
|
const vid = document.getElementById('srcVideo');
|
|
|
|
|
|
vid.src = '';
|
|
|
|
|
|
|
|
|
|
|
|
let url = state.streamCache[asset.id];
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
const r = await getAssetStreamUrl(asset.id);
|
2026-05-19 22:47:33 -04:00
|
|
|
|
if (r.success && r.data && r.data.url) {
|
|
|
|
|
|
url = r.data.url;
|
|
|
|
|
|
state.streamCache[asset.id] = url;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast('No proxy — clip usable in timeline only', '', 'info');
|
|
|
|
|
|
}
|
2026-05-18 20:10:25 -04:00
|
|
|
|
}
|
2026-05-19 22:47:33 -04:00
|
|
|
|
if (url) { vid.src = url; vid.load(); }
|
2026-05-18 20:10:25 -04:00
|
|
|
|
updateSrcInOutMarkers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function markSrcIn() {
|
|
|
|
|
|
const vid = document.getElementById('srcVideo');
|
|
|
|
|
|
state.srcIn = vid.currentTime;
|
|
|
|
|
|
updateSrcInOutMarkers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function markSrcOut() {
|
|
|
|
|
|
const vid = document.getElementById('srcVideo');
|
|
|
|
|
|
state.srcOut = vid.currentTime;
|
|
|
|
|
|
updateSrcInOutMarkers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateSrcInOutMarkers() {
|
|
|
|
|
|
const inBtn = document.getElementById('srcInBtn');
|
|
|
|
|
|
const outBtn = document.getElementById('srcOutBtn');
|
|
|
|
|
|
inBtn.title = state.srcIn != null ? 'In: ' + TC.framesToTC(TC.secondsToFrames(state.srcIn)) : 'Mark In (I)';
|
|
|
|
|
|
outBtn.title = state.srcOut != null ? 'Out: ' + TC.framesToTC(TC.secondsToFrames(state.srcOut)) : 'Mark Out (O)';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function doInsert() { _addToTimeline(false); }
|
|
|
|
|
|
function doOverwrite() { _addToTimeline(true); }
|
|
|
|
|
|
|
|
|
|
|
|
function _addToTimeline(overwrite) {
|
|
|
|
|
|
if (!state.sourceAsset) { toast('Load a clip first', '', 'warning'); return; }
|
|
|
|
|
|
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
|
|
|
|
|
|
|
|
|
|
|
|
const vid = document.getElementById('srcVideo');
|
|
|
|
|
|
const srcIn = state.srcIn != null ? state.srcIn : 0;
|
|
|
|
|
|
const srcOut = state.srcOut != null ? state.srcOut : (vid.duration || (state.sourceAsset.duration_ms || 10000) / 1000);
|
|
|
|
|
|
|
|
|
|
|
|
Timeline.addClip(
|
|
|
|
|
|
Object.assign({}, state.sourceAsset, { streamUrl: state.streamCache[state.sourceAsset.id] }),
|
|
|
|
|
|
srcIn, srcOut, 0
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// PROGRAM MONITOR
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function setupProgramMonitor() {
|
|
|
|
|
|
const vid = document.getElementById('pgmVideo');
|
|
|
|
|
|
const scrub = document.getElementById('pgmScrub');
|
|
|
|
|
|
const btn = document.getElementById('pgmPlayBtn');
|
|
|
|
|
|
|
|
|
|
|
|
vid.addEventListener('timeupdate', onPgmTimeUpdate);
|
|
|
|
|
|
vid.addEventListener('ended', onPgmEnded);
|
|
|
|
|
|
|
|
|
|
|
|
scrub.addEventListener('input', function() {
|
|
|
|
|
|
if (!state.pgmClips.length) return;
|
|
|
|
|
|
const totalDuration = pgmTotalDuration();
|
|
|
|
|
|
if (totalDuration <= 0) return;
|
|
|
|
|
|
const targetSecs = (scrub.value / 1000) * totalDuration;
|
|
|
|
|
|
seekPgmToSeconds(targetSecs);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
btn.onclick = togglePgmPlay;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pgmTotalDuration() {
|
|
|
|
|
|
if (!state.pgmClips.length) return 0;
|
|
|
|
|
|
const last = state.pgmClips[state.pgmClips.length - 1];
|
|
|
|
|
|
return TC.framesToSeconds(last.timeline_out_frames);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onPgmTimeUpdate() {
|
|
|
|
|
|
if (!state.pgmPlaying) return;
|
|
|
|
|
|
const vid = document.getElementById('pgmVideo');
|
|
|
|
|
|
const clip = state.pgmClips[state.pgmClipIdx];
|
|
|
|
|
|
if (!clip) return;
|
|
|
|
|
|
|
|
|
|
|
|
const srcOutSecs = TC.framesToSeconds(clip.source_out_frames);
|
|
|
|
|
|
if (vid.currentTime >= srcOutSecs) {
|
|
|
|
|
|
loadNextPgmClip();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const elapsed = vid.currentTime - TC.framesToSeconds(clip.source_in_frames);
|
|
|
|
|
|
const tlFrames = clip.timeline_in_frames + TC.secondsToFrames(elapsed);
|
|
|
|
|
|
Timeline.setPlayhead(tlFrames);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('pgmTC').textContent = TC.framesToTC(tlFrames);
|
|
|
|
|
|
const total = pgmTotalDuration();
|
|
|
|
|
|
if (total > 0)
|
|
|
|
|
|
document.getElementById('pgmScrub').value =
|
|
|
|
|
|
Math.round((TC.framesToSeconds(tlFrames) / total) * 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onPgmEnded() {
|
|
|
|
|
|
loadNextPgmClip();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadNextPgmClip() {
|
|
|
|
|
|
state.pgmClipIdx++;
|
|
|
|
|
|
const clip = state.pgmClips[state.pgmClipIdx];
|
|
|
|
|
|
if (!clip) { stopPgm(); return; }
|
|
|
|
|
|
await _loadClipToPgm(clip);
|
|
|
|
|
|
document.getElementById('pgmVideo').play().catch(function() {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function _loadClipToPgm(clip) {
|
|
|
|
|
|
let url = state.streamCache[clip.asset_id];
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
const r = await getAssetStreamUrl(clip.asset_id);
|
|
|
|
|
|
if (!r.success || !r.data || !r.data.url) return;
|
|
|
|
|
|
url = state.streamCache[clip.asset_id] = r.data.url;
|
|
|
|
|
|
}
|
|
|
|
|
|
const vid = document.getElementById('pgmVideo');
|
|
|
|
|
|
if (vid.src !== url) { vid.src = url; vid.load(); }
|
|
|
|
|
|
await new Promise(function(res) {
|
|
|
|
|
|
if (vid.readyState >= 1) { res(); return; }
|
|
|
|
|
|
vid.addEventListener('loadedmetadata', res, { once: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
vid.currentTime = TC.framesToSeconds(clip.source_in_frames);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function togglePgmPlay() {
|
|
|
|
|
|
if (state.pgmPlaying) { stopPgm(); return; }
|
|
|
|
|
|
if (!state.seq) return;
|
|
|
|
|
|
state.pgmClips = (state.seq.clips || [])
|
|
|
|
|
|
.filter(function(c) { return c.track === 0; })
|
|
|
|
|
|
.sort(function(a, b) { return a.timeline_in_frames - b.timeline_in_frames; });
|
|
|
|
|
|
if (!state.pgmClips.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const ph = Timeline.getPlayhead();
|
|
|
|
|
|
state.pgmClipIdx = state.pgmClips.findIndex(function(c) {
|
|
|
|
|
|
return ph >= c.timeline_in_frames && ph < c.timeline_out_frames;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (state.pgmClipIdx < 0) state.pgmClipIdx = 0;
|
|
|
|
|
|
|
|
|
|
|
|
state.pgmPlaying = true;
|
|
|
|
|
|
document.getElementById('pgmPlayBtn').textContent = '⏸';
|
|
|
|
|
|
const clip = state.pgmClips[state.pgmClipIdx];
|
|
|
|
|
|
await _loadClipToPgm(clip);
|
|
|
|
|
|
document.getElementById('pgmVideo').play().catch(function() {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopPgm() {
|
|
|
|
|
|
state.pgmPlaying = false;
|
|
|
|
|
|
state.pgmClipIdx = -1;
|
|
|
|
|
|
document.getElementById('pgmPlayBtn').textContent = '▶';
|
|
|
|
|
|
document.getElementById('pgmVideo').pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function seekPgmToSeconds(targetSecs) {
|
|
|
|
|
|
stopPgm();
|
|
|
|
|
|
const targetFrames = TC.secondsToFrames(targetSecs);
|
|
|
|
|
|
Timeline.setPlayhead(targetFrames);
|
|
|
|
|
|
document.getElementById('pgmTC').textContent = TC.framesToTC(targetFrames);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateProgramScrub() {
|
|
|
|
|
|
document.getElementById('pgmScrub').value = 0;
|
|
|
|
|
|
document.getElementById('pgmTC').textContent = '00:00:00;00';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// TIMELINE CALLBACKS
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function onClipsChanged(clips) {
|
|
|
|
|
|
if (!state.seq) return;
|
|
|
|
|
|
state.history = state.history.slice(0, state.historyIdx + 1);
|
|
|
|
|
|
state.history.push(cloneClips(clips));
|
|
|
|
|
|
state.historyIdx = state.history.length - 1;
|
|
|
|
|
|
state.seq.clips = clips;
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onPlayheadMoved(frames) {
|
|
|
|
|
|
document.getElementById('pgmTC').textContent = TC.framesToTC(frames);
|
|
|
|
|
|
const total = pgmTotalDuration();
|
|
|
|
|
|
if (total > 0)
|
|
|
|
|
|
document.getElementById('pgmScrub').value =
|
|
|
|
|
|
Math.round((TC.framesToSeconds(frames) / total) * 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// AUTO-SAVE
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function markDirty() {
|
|
|
|
|
|
state.isDirty = true;
|
|
|
|
|
|
setSaveStatus('Unsaved');
|
|
|
|
|
|
clearTimeout(state.saveTimer);
|
|
|
|
|
|
state.saveTimer = setTimeout(saveSequence, 2000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveSequence() {
|
|
|
|
|
|
if (!state.seq || !state.isDirty) return;
|
|
|
|
|
|
clearTimeout(state.saveTimer);
|
|
|
|
|
|
setSaveStatus('Saving…');
|
|
|
|
|
|
const clips = (state.seq.clips || []).map(function(c) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
asset_id: c.asset_id,
|
|
|
|
|
|
track: c.track,
|
|
|
|
|
|
timeline_in_frames: c.timeline_in_frames,
|
|
|
|
|
|
timeline_out_frames: c.timeline_out_frames,
|
|
|
|
|
|
source_in_frames: c.source_in_frames,
|
|
|
|
|
|
source_out_frames: c.source_out_frames,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
const r = await syncSequenceClips(state.seq.id, clips);
|
|
|
|
|
|
if (r.success) {
|
|
|
|
|
|
state.isDirty = false;
|
|
|
|
|
|
setSaveStatus('Saved');
|
|
|
|
|
|
setTimeout(function() { setSaveStatus(''); }, 2000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSaveStatus('Save failed');
|
|
|
|
|
|
toast('Save failed', r.error, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setSaveStatus(msg) {
|
|
|
|
|
|
document.getElementById('saveStatus').textContent = msg;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// UNDO / REDO
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function cloneClips(clips) {
|
|
|
|
|
|
return clips.map(function(c) { return Object.assign({}, c); });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function undo() {
|
|
|
|
|
|
if (state.historyIdx <= 0) return;
|
|
|
|
|
|
state.historyIdx--;
|
|
|
|
|
|
applyHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function redo() {
|
|
|
|
|
|
if (state.historyIdx >= state.history.length - 1) return;
|
|
|
|
|
|
state.historyIdx++;
|
|
|
|
|
|
applyHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyHistory() {
|
|
|
|
|
|
const clips = cloneClips(state.history[state.historyIdx]);
|
|
|
|
|
|
state.seq.clips = clips;
|
|
|
|
|
|
Timeline.render(clips, { fps: 59.94 });
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// TOOLBAR + KEYBOARD
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function setupToolbar() {
|
|
|
|
|
|
const btns = {
|
|
|
|
|
|
select: document.getElementById('toolSelect'),
|
|
|
|
|
|
razor: document.getElementById('toolRazor'),
|
|
|
|
|
|
hand: document.getElementById('toolHand'),
|
|
|
|
|
|
};
|
|
|
|
|
|
Object.entries(btns).forEach(function(entry) {
|
|
|
|
|
|
var tool = entry[0];
|
|
|
|
|
|
var btn = entry[1];
|
|
|
|
|
|
btn.onclick = function() {
|
|
|
|
|
|
Object.values(btns).forEach(function(b) { b.classList.remove('active'); });
|
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
|
Timeline.setTool(tool);
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('undoBtn').onclick = undo;
|
|
|
|
|
|
document.getElementById('redoBtn').onclick = redo;
|
|
|
|
|
|
document.getElementById('saveBtn').onclick = saveSequence;
|
|
|
|
|
|
document.getElementById('exportEdlBtn').onclick = function() {
|
|
|
|
|
|
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
|
|
|
|
|
|
exportSequenceEDL(state.seq.id, (state.seq.name || 'sequence') + '.edl');
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setupKeyboard() {
|
|
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
|
|
|
|
const tag = document.activeElement.tagName;
|
|
|
|
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (e.code === 'Space') { e.preventDefault(); togglePgmPlay(); return; }
|
|
|
|
|
|
if (e.key === 'j' || e.key === 'J') { stopPgm(); return; }
|
|
|
|
|
|
if (e.key === 'k' || e.key === 'K') { stopPgm(); return; }
|
|
|
|
|
|
if (e.key === 'l' || e.key === 'L') { togglePgmPlay(); return; }
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'i' || e.key === 'I') { markSrcIn(); return; }
|
|
|
|
|
|
if (e.key === 'o' || e.key === 'O') { markSrcOut(); return; }
|
|
|
|
|
|
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') { e.preventDefault(); redo(); return; }
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); return; }
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveSequence(); return; }
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'v' || e.key === 'V') updateToolbarActive('select');
|
|
|
|
|
|
if (e.key === 'c' || e.key === 'C') updateToolbarActive('razor');
|
|
|
|
|
|
if (e.key === 'h' || e.key === 'H') updateToolbarActive('hand');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateToolbarActive(tool) {
|
|
|
|
|
|
const map = { select: 'toolSelect', razor: 'toolRazor', hand: 'toolHand' };
|
|
|
|
|
|
Object.keys(map).forEach(function(k) {
|
|
|
|
|
|
document.getElementById(map[k]).classList.remove('active');
|
|
|
|
|
|
});
|
|
|
|
|
|
if (map[tool]) document.getElementById(map[tool]).classList.add('active');
|
2026-05-18 23:53:38 -04:00
|
|
|
|
Timeline.setTool(tool);
|
2026-05-18 20:10:25 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// NEW SEQUENCE PANEL
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function setupNewSeqPanel() {
|
|
|
|
|
|
document.getElementById('newSeqBtn').onclick = function() { openPanel('seq'); };
|
|
|
|
|
|
document.getElementById('closeSeqPanel').onclick = function() { closePanel('seq'); };
|
|
|
|
|
|
document.getElementById('cancelSeqBtn').onclick = function() { closePanel('seq'); };
|
|
|
|
|
|
document.getElementById('seqOverlay').onclick = function() { closePanel('seq'); };
|
|
|
|
|
|
document.getElementById('saveSeqBtn').onclick = createNewSequence;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createNewSequence() {
|
|
|
|
|
|
const name = document.getElementById('newSeqName').value.trim() || 'Sequence ' + (state.sequences.length + 1);
|
|
|
|
|
|
const r = await createSequence({ project_id: state.projectId, name: name });
|
|
|
|
|
|
if (!r.success) { toast('Failed to create sequence', r.error, 'error'); return; }
|
|
|
|
|
|
closePanel('seq');
|
|
|
|
|
|
document.getElementById('newSeqName').value = '';
|
|
|
|
|
|
state.sequences.unshift(r.data);
|
|
|
|
|
|
renderSeqSelect();
|
|
|
|
|
|
await openSequence(r.data.id);
|
|
|
|
|
|
toast('Sequence created', name, 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openPanel(name) {
|
|
|
|
|
|
document.getElementById(name + 'Panel').classList.add('open');
|
|
|
|
|
|
document.getElementById(name + 'Overlay').classList.add('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
function closePanel(name) {
|
|
|
|
|
|
document.getElementById(name + 'Panel').classList.remove('open');
|
|
|
|
|
|
document.getElementById(name + 'Overlay').classList.remove('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// UTILITIES
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
|
|
function toast(title, msg, type) {
|
|
|
|
|
|
type = type || 'info';
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'toast toast--' + type;
|
|
|
|
|
|
el.innerHTML = '<div class="toast-body"><div class="toast-title">' + esc(title) + '</div>' +
|
|
|
|
|
|
(msg ? '<div class="toast-msg">' + esc(msg) + '</div>' : '') + '</div>';
|
|
|
|
|
|
document.getElementById('toastContainer').appendChild(el);
|
|
|
|
|
|
setTimeout(function() { el.remove(); }, 4000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function esc(s) {
|
|
|
|
|
|
if (!s) return '';
|
|
|
|
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<script src="js/auth-guard.js"></script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|