Spaces:
Paused
Paused
Zhen Ye Claude Opus 4.6 commited on
Commit ·
f2f129a
1
Parent(s): d35f19d
feat: add timeline heatmap with detection highlights for Tab 2 video player
Browse filesAdd a visual progress bar beneath the video in Track & Monitor tab that
shows which frames have detected objects. Backend summary endpoint returns
per-frame detection counts in one call, frontend renders a color-coded
heatmap (amber for few tracks, red for 5+) with a scrubable playhead.
- Backend: GET /detect/tracks/{job_id}/summary endpoint + JobStorage.get_track_summary()
- Frontend: timeline.js canvas bar with heatmap render + click-to-seek
- Frontend: loadSummary() fetches heatmap data when job completes
- CLAUDE.md: document parallel execution with team mode pattern
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CLAUDE.md +26 -0
- app.py +32 -0
- frontend/index.html +5 -0
- frontend/js/api/client.js +2 -0
- frontend/js/core/state.js +2 -1
- frontend/js/core/timeline.js +165 -0
- frontend/js/core/tracker.js +3 -0
- frontend/js/main.js +9 -1
- frontend/style.css +19 -0
- jobs/storage.py +9 -0
CLAUDE.md
CHANGED
|
@@ -220,6 +220,32 @@ _schedule_cleanup(background_tasks, input_path)
|
|
| 220 |
_schedule_cleanup(background_tasks, output_path)
|
| 221 |
```
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
## Performance Notes
|
| 224 |
|
| 225 |
- **Detector Caching**: Models are loaded once and cached via `@lru_cache`
|
|
|
|
| 220 |
_schedule_cleanup(background_tasks, output_path)
|
| 221 |
```
|
| 222 |
|
| 223 |
+
## Parallel Execution with Team Mode
|
| 224 |
+
|
| 225 |
+
When implementing features that touch independent subsystems, **use team mode (parallel agents with worktree isolation)** for maximum efficiency.
|
| 226 |
+
|
| 227 |
+
### When to Parallelize
|
| 228 |
+
- Backend (Python) + Frontend (JS) changes — always parallelizable
|
| 229 |
+
- Independent API endpoints or UI components
|
| 230 |
+
- Test writing + implementation when in different files
|
| 231 |
+
- Any 2+ tasks that don't modify the same files
|
| 232 |
+
|
| 233 |
+
### How to Parallelize
|
| 234 |
+
1. Identify independent task domains (e.g., backend vs frontend)
|
| 235 |
+
2. Dispatch one agent per domain using `isolation: "worktree"`
|
| 236 |
+
3. Each agent works in its own git worktree — no conflicts
|
| 237 |
+
4. Merge results back: `git checkout <worktree-branch> -- <files>`
|
| 238 |
+
5. Clean up worktrees after merge
|
| 239 |
+
|
| 240 |
+
### Example
|
| 241 |
+
```
|
| 242 |
+
Agent 1 (worktree): Backend — app.py, jobs/storage.py
|
| 243 |
+
Agent 2 (worktree): Frontend — timeline.js, client.js
|
| 244 |
+
→ Both run simultaneously, merge when done
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
**Default to parallel** when tasks are independent. Sequential only when one task's output is the other's input.
|
| 248 |
+
|
| 249 |
## Performance Notes
|
| 250 |
|
| 251 |
- **Detector Caching**: Models are loaded once and cached via `@lru_cache`
|
app.py
CHANGED
|
@@ -593,6 +593,38 @@ async def detect_status(job_id: str):
|
|
| 593 |
}
|
| 594 |
|
| 595 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
@app.get("/detect/tracks/{job_id}/{frame_idx}")
|
| 597 |
async def get_frame_tracks(job_id: str, frame_idx: int):
|
| 598 |
"""Retrieve detections (with tracking info) for a specific frame."""
|
|
|
|
| 593 |
}
|
| 594 |
|
| 595 |
|
| 596 |
+
@app.get("/detect/tracks/{job_id}/summary")
|
| 597 |
+
async def get_track_summary_endpoint(job_id: str):
|
| 598 |
+
"""Return per-frame detection counts for timeline heatmap."""
|
| 599 |
+
from jobs.storage import get_track_summary, get_job_storage
|
| 600 |
+
import cv2
|
| 601 |
+
|
| 602 |
+
job = get_job_storage().get(job_id)
|
| 603 |
+
if not job:
|
| 604 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 605 |
+
|
| 606 |
+
summary = get_track_summary(job_id)
|
| 607 |
+
|
| 608 |
+
total_frames = 0
|
| 609 |
+
fps = 30.0
|
| 610 |
+
video_path = job.output_video_path
|
| 611 |
+
if video_path:
|
| 612 |
+
cap = cv2.VideoCapture(video_path)
|
| 613 |
+
if cap.isOpened():
|
| 614 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 615 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 616 |
+
cap.release()
|
| 617 |
+
|
| 618 |
+
if total_frames == 0 and summary:
|
| 619 |
+
total_frames = max(summary.keys()) + 1
|
| 620 |
+
|
| 621 |
+
return {
|
| 622 |
+
"total_frames": total_frames,
|
| 623 |
+
"fps": fps,
|
| 624 |
+
"frames": summary,
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
|
| 628 |
@app.get("/detect/tracks/{job_id}/{frame_idx}")
|
| 629 |
async def get_frame_tracks(job_id: str, frame_idx: int):
|
| 630 |
"""Retrieve detections (with tracking info) for a specific frame."""
|
frontend/index.html
CHANGED
|
@@ -222,6 +222,10 @@
|
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
<div class="btnrow mt-md">
|
| 226 |
<button id="btnEngage" class="btn">Track</button>
|
| 227 |
<button id="btnPause" class="btn secondary">Pause</button>
|
|
@@ -287,6 +291,7 @@
|
|
| 287 |
<script src="./js/ui/logging.js"></script>
|
| 288 |
<script src="./js/core/gptMapping.js"></script>
|
| 289 |
<script src="./js/core/tracker.js"></script>
|
|
|
|
| 290 |
<script src="./js/api/client.js"></script>
|
| 291 |
<script src="./js/ui/overlays.js"></script>
|
| 292 |
<script src="./js/ui/cards.js"></script>
|
|
|
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
|
| 225 |
+
<div id="timelineWrap" class="timeline-wrap">
|
| 226 |
+
<canvas id="timelineBar"></canvas>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
<div class="btnrow mt-md">
|
| 230 |
<button id="btnEngage" class="btn">Track</button>
|
| 231 |
<button id="btnPause" class="btn secondary">Pause</button>
|
|
|
|
| 291 |
<script src="./js/ui/logging.js"></script>
|
| 292 |
<script src="./js/core/gptMapping.js"></script>
|
| 293 |
<script src="./js/core/tracker.js"></script>
|
| 294 |
+
<script src="./js/core/timeline.js"></script>
|
| 295 |
<script src="./js/api/client.js"></script>
|
| 296 |
<script src="./js/ui/overlays.js"></script>
|
| 297 |
<script src="./js/ui/cards.js"></script>
|
frontend/js/api/client.js
CHANGED
|
@@ -199,6 +199,8 @@ APP.api.client.pollAsyncJob = async function () {
|
|
| 199 |
|
| 200 |
clearInterval(state.hf.asyncPollInterval);
|
| 201 |
state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
|
|
|
|
|
|
|
| 202 |
state.hf.asyncJobId = null;
|
| 203 |
setHfStatus("ready");
|
| 204 |
resolve();
|
|
|
|
| 199 |
|
| 200 |
clearInterval(state.hf.asyncPollInterval);
|
| 201 |
state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
|
| 202 |
+
// Fetch timeline summary for heatmap
|
| 203 |
+
APP.core.timeline.loadSummary();
|
| 204 |
state.hf.asyncJobId = null;
|
| 205 |
setHfStatus("ready");
|
| 206 |
resolve();
|
frontend/js/core/state.js
CHANGED
|
@@ -59,7 +59,8 @@ APP.core.state = {
|
|
| 59 |
lastFrameTime: 0,
|
| 60 |
frameCount: 0,
|
| 61 |
_lastCardRenderFrame: 0, // Frame count at last card render
|
| 62 |
-
_gptBusy: false
|
|
|
|
| 63 |
},
|
| 64 |
|
| 65 |
frame: {
|
|
|
|
| 59 |
lastFrameTime: 0,
|
| 60 |
frameCount: 0,
|
| 61 |
_lastCardRenderFrame: 0, // Frame count at last card render
|
| 62 |
+
_gptBusy: false, // Prevent overlapping GPT calls
|
| 63 |
+
heatmap: {} // { frameIdx: trackCount } for timeline heatmap
|
| 64 |
},
|
| 65 |
|
| 66 |
frame: {
|
frontend/js/core/timeline.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* timeline.js — Lazy heatmap timeline bar for Tab 2 (Track & Monitor).
|
| 3 |
+
*
|
| 4 |
+
* Renders a thin canvas bar showing:
|
| 5 |
+
* - Detection density heatmap (colored by track count per frame)
|
| 6 |
+
* - White playhead at current video position
|
| 7 |
+
* - Click-to-seek interaction
|
| 8 |
+
*
|
| 9 |
+
* Heatmap data is populated lazily by tracker.syncWithBackend() writing
|
| 10 |
+
* track counts into state.tracker.heatmap[frameIdx].
|
| 11 |
+
*/
|
| 12 |
+
APP.core.timeline = {};
|
| 13 |
+
|
| 14 |
+
(function () {
|
| 15 |
+
const COLORS = {
|
| 16 |
+
bg: "#1a1a2e", // unvisited
|
| 17 |
+
empty: "#2a2a3e", // visited, 0 tracks
|
| 18 |
+
low: "rgba(245, 158, 11, 0.4)", // 1-2 tracks
|
| 19 |
+
mid: "rgba(245, 158, 11, 0.7)", // 3-4 tracks
|
| 20 |
+
high: "rgba(239, 68, 68, 0.9)", // 5+ tracks
|
| 21 |
+
playhead: "#ffffff",
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
function colorForCount(count) {
|
| 25 |
+
if (count === undefined) return COLORS.bg;
|
| 26 |
+
if (count === 0) return COLORS.empty;
|
| 27 |
+
if (count <= 2) return COLORS.low;
|
| 28 |
+
if (count <= 4) return COLORS.mid;
|
| 29 |
+
return COLORS.high;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** Render the timeline heatmap + playhead. Called every RAF frame. */
|
| 33 |
+
APP.core.timeline.render = function () {
|
| 34 |
+
const { state } = APP.core;
|
| 35 |
+
const { $ } = APP.core.utils;
|
| 36 |
+
const canvas = $("#timelineBar");
|
| 37 |
+
const video = $("#videoEngage");
|
| 38 |
+
if (!canvas || !video) return;
|
| 39 |
+
|
| 40 |
+
// Cache duration once metadata loads (video.duration is NaN before that)
|
| 41 |
+
if (video.duration && isFinite(video.duration)) {
|
| 42 |
+
APP.core.timeline._cachedDuration = video.duration;
|
| 43 |
+
}
|
| 44 |
+
const duration = APP.core.timeline._cachedDuration;
|
| 45 |
+
if (!duration) return;
|
| 46 |
+
|
| 47 |
+
const wrap = canvas.parentElement;
|
| 48 |
+
const rect = wrap.getBoundingClientRect();
|
| 49 |
+
const dpr = window.devicePixelRatio || 1;
|
| 50 |
+
const w = Math.floor(rect.width);
|
| 51 |
+
const h = 14;
|
| 52 |
+
|
| 53 |
+
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
| 54 |
+
canvas.width = w * dpr;
|
| 55 |
+
canvas.height = h * dpr;
|
| 56 |
+
canvas.style.width = w + "px";
|
| 57 |
+
canvas.style.height = h + "px";
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const ctx = canvas.getContext("2d");
|
| 61 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 62 |
+
|
| 63 |
+
const fps = 30;
|
| 64 |
+
const totalFrames = Math.ceil(duration * fps);
|
| 65 |
+
if (totalFrames <= 0) return;
|
| 66 |
+
|
| 67 |
+
const heatmap = state.tracker.heatmap;
|
| 68 |
+
|
| 69 |
+
// Draw heatmap segments
|
| 70 |
+
// Group consecutive pixels mapping to same color to reduce draw calls
|
| 71 |
+
const pxPerFrame = w / totalFrames;
|
| 72 |
+
|
| 73 |
+
if (pxPerFrame >= 1) {
|
| 74 |
+
// Enough room to draw per-frame
|
| 75 |
+
for (let f = 0; f < totalFrames; f++) {
|
| 76 |
+
const x = Math.floor(f * pxPerFrame);
|
| 77 |
+
const nx = Math.floor((f + 1) * pxPerFrame);
|
| 78 |
+
ctx.fillStyle = colorForCount(heatmap[f]);
|
| 79 |
+
ctx.fillRect(x, 0, Math.max(nx - x, 1), h);
|
| 80 |
+
}
|
| 81 |
+
} else {
|
| 82 |
+
// More frames than pixels — bucket frames per pixel
|
| 83 |
+
for (let px = 0; px < w; px++) {
|
| 84 |
+
const fStart = Math.floor((px / w) * totalFrames);
|
| 85 |
+
const fEnd = Math.floor(((px + 1) / w) * totalFrames);
|
| 86 |
+
let maxCount;
|
| 87 |
+
for (let f = fStart; f < fEnd; f++) {
|
| 88 |
+
const c = heatmap[f];
|
| 89 |
+
if (c !== undefined && (maxCount === undefined || c > maxCount)) {
|
| 90 |
+
maxCount = c;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
ctx.fillStyle = colorForCount(maxCount);
|
| 94 |
+
ctx.fillRect(px, 0, 1, h);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Draw playhead
|
| 99 |
+
const progress = video.currentTime / duration;
|
| 100 |
+
const px = Math.round(progress * w);
|
| 101 |
+
ctx.fillStyle = COLORS.playhead;
|
| 102 |
+
ctx.fillRect(px - 1, 0, 2, h);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
/** Initialize click-to-seek on the timeline canvas. */
|
| 106 |
+
APP.core.timeline.init = function () {
|
| 107 |
+
const { $ } = APP.core.utils;
|
| 108 |
+
const canvas = $("#timelineBar");
|
| 109 |
+
const video = $("#videoEngage");
|
| 110 |
+
if (!canvas || !video) return;
|
| 111 |
+
|
| 112 |
+
let dragging = false;
|
| 113 |
+
|
| 114 |
+
function seek(e) {
|
| 115 |
+
const rect = canvas.getBoundingClientRect();
|
| 116 |
+
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
| 117 |
+
const ratio = x / rect.width;
|
| 118 |
+
const dur = APP.core.timeline._cachedDuration || video.duration;
|
| 119 |
+
if (dur && isFinite(dur)) {
|
| 120 |
+
video.currentTime = ratio * dur;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
canvas.addEventListener("mousedown", function (e) {
|
| 125 |
+
dragging = true;
|
| 126 |
+
seek(e);
|
| 127 |
+
});
|
| 128 |
+
window.addEventListener("mousemove", function (e) {
|
| 129 |
+
if (dragging) seek(e);
|
| 130 |
+
});
|
| 131 |
+
window.addEventListener("mouseup", function () {
|
| 132 |
+
dragging = false;
|
| 133 |
+
});
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
/** Fetch track summary from backend and populate heatmap + duration. */
|
| 137 |
+
APP.core.timeline.loadSummary = async function () {
|
| 138 |
+
const { state } = APP.core;
|
| 139 |
+
const jobId = state.hf.asyncJobId || state.hf.completedJobId;
|
| 140 |
+
if (!jobId || !state.hf.baseUrl) return;
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
const resp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/summary`);
|
| 144 |
+
if (!resp.ok) return;
|
| 145 |
+
|
| 146 |
+
const data = await resp.json();
|
| 147 |
+
const frames = data.frames || {};
|
| 148 |
+
|
| 149 |
+
// Populate heatmap — keys come as strings from JSON, convert to int
|
| 150 |
+
state.tracker.heatmap = {};
|
| 151 |
+
for (const [idx, count] of Object.entries(frames)) {
|
| 152 |
+
state.tracker.heatmap[parseInt(idx, 10)] = count;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Cache duration from backend metadata (bypass video.duration dependency)
|
| 156 |
+
if (data.total_frames > 0 && data.fps > 0) {
|
| 157 |
+
APP.core.timeline._cachedDuration = data.total_frames / data.fps;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
console.log(`[timeline] Loaded summary: ${Object.keys(frames).length} frames, duration=${APP.core.timeline._cachedDuration}s`);
|
| 161 |
+
} catch (e) {
|
| 162 |
+
console.warn("[timeline] Failed to load summary", e);
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
+
})();
|
frontend/js/core/tracker.js
CHANGED
|
@@ -250,6 +250,9 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
|
|
| 250 |
APP.ui.logging.log(`New objects: ${brandNew.map(t => t.id).join(", ")}`, "t");
|
| 251 |
}
|
| 252 |
|
|
|
|
|
|
|
|
|
|
| 253 |
// Update state
|
| 254 |
state.tracker.tracks = newTracks;
|
| 255 |
state.detections = newTracks; // Keep synced
|
|
|
|
| 250 |
APP.ui.logging.log(`New objects: ${brandNew.map(t => t.id).join(", ")}`, "t");
|
| 251 |
}
|
| 252 |
|
| 253 |
+
// Cache track count for timeline heatmap
|
| 254 |
+
state.tracker.heatmap[frameIdx] = newTracks.length;
|
| 255 |
+
|
| 256 |
// Update state
|
| 257 |
state.tracker.tracks = newTracks;
|
| 258 |
state.detections = newTracks; // Keep synced
|
frontend/js/main.js
CHANGED
|
@@ -63,6 +63,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 63 |
// Enable click-to-select on engage overlay
|
| 64 |
initClickHandler();
|
| 65 |
|
|
|
|
|
|
|
|
|
|
| 66 |
// Start main loop
|
| 67 |
requestAnimationFrame(loop);
|
| 68 |
|
|
@@ -192,6 +195,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 192 |
state.tracker.tracks = [];
|
| 193 |
state.tracker.running = false;
|
| 194 |
state.tracker.nextId = 1;
|
|
|
|
|
|
|
| 195 |
renderFrameTrackList();
|
| 196 |
log("Tracking reset.", "t");
|
| 197 |
});
|
|
@@ -725,7 +730,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 725 |
const jobId = state.hf.asyncJobId || state.hf.completedJobId;
|
| 726 |
if (jobId && (t - state.tracker.lastHFSync > 333)) {
|
| 727 |
const frameIdx = Math.floor(videoEngage.currentTime * 30);
|
| 728 |
-
|
|
|
|
|
|
|
| 729 |
state.tracker.lastHFSync = t;
|
| 730 |
}
|
| 731 |
}
|
|
@@ -773,6 +780,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 773 |
if (renderFrameOverlay) renderFrameOverlay();
|
| 774 |
if (renderEngageOverlay) renderEngageOverlay();
|
| 775 |
if (tickAgentCursor) tickAgentCursor();
|
|
|
|
| 776 |
|
| 777 |
requestAnimationFrame(loop);
|
| 778 |
}
|
|
|
|
| 63 |
// Enable click-to-select on engage overlay
|
| 64 |
initClickHandler();
|
| 65 |
|
| 66 |
+
// Initialize timeline seek interaction
|
| 67 |
+
APP.core.timeline.init();
|
| 68 |
+
|
| 69 |
// Start main loop
|
| 70 |
requestAnimationFrame(loop);
|
| 71 |
|
|
|
|
| 195 |
state.tracker.tracks = [];
|
| 196 |
state.tracker.running = false;
|
| 197 |
state.tracker.nextId = 1;
|
| 198 |
+
state.tracker.heatmap = {};
|
| 199 |
+
APP.core.timeline._cachedDuration = null;
|
| 200 |
renderFrameTrackList();
|
| 201 |
log("Tracking reset.", "t");
|
| 202 |
});
|
|
|
|
| 730 |
const jobId = state.hf.asyncJobId || state.hf.completedJobId;
|
| 731 |
if (jobId && (t - state.tracker.lastHFSync > 333)) {
|
| 732 |
const frameIdx = Math.floor(videoEngage.currentTime * 30);
|
| 733 |
+
if (isFinite(frameIdx) && frameIdx >= 0) {
|
| 734 |
+
APP.core.tracker.syncWithBackend(frameIdx);
|
| 735 |
+
}
|
| 736 |
state.tracker.lastHFSync = t;
|
| 737 |
}
|
| 738 |
}
|
|
|
|
| 780 |
if (renderFrameOverlay) renderFrameOverlay();
|
| 781 |
if (renderEngageOverlay) renderEngageOverlay();
|
| 782 |
if (tickAgentCursor) tickAgentCursor();
|
| 783 |
+
APP.core.timeline.render();
|
| 784 |
|
| 785 |
requestAnimationFrame(loop);
|
| 786 |
}
|
frontend/style.css
CHANGED
|
@@ -545,6 +545,25 @@ input[type="number"]:focus {
|
|
| 545 |
line-height: 1.4;
|
| 546 |
}
|
| 547 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
/* =========================================
|
| 549 |
Lists & Tables
|
| 550 |
========================================= */
|
|
|
|
| 545 |
line-height: 1.4;
|
| 546 |
}
|
| 547 |
|
| 548 |
+
/* =========================================
|
| 549 |
+
Timeline Heatmap Bar
|
| 550 |
+
========================================= */
|
| 551 |
+
|
| 552 |
+
.timeline-wrap {
|
| 553 |
+
width: 100%;
|
| 554 |
+
margin-top: 6px;
|
| 555 |
+
border-radius: 4px;
|
| 556 |
+
overflow: hidden;
|
| 557 |
+
background: #1a1a2e;
|
| 558 |
+
cursor: pointer;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
.timeline-wrap canvas {
|
| 562 |
+
display: block;
|
| 563 |
+
width: 100%;
|
| 564 |
+
height: 14px;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
/* =========================================
|
| 568 |
Lists & Tables
|
| 569 |
========================================= */
|
jobs/storage.py
CHANGED
|
@@ -55,6 +55,12 @@ class JobStorage:
|
|
| 55 |
with self._lock:
|
| 56 |
return self._tracks.get(job_id, {}).get(frame_idx, [])
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def get(self, job_id: str) -> Optional[JobInfo]:
|
| 59 |
with self._lock:
|
| 60 |
return self._jobs.get(job_id)
|
|
@@ -98,3 +104,6 @@ def get_track_data(job_id: str, frame_idx: int) -> list:
|
|
| 98 |
|
| 99 |
def set_track_data(job_id: str, frame_idx: int, tracks: list) -> None:
|
| 100 |
get_job_storage().set_track_data(job_id, frame_idx, tracks)
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
with self._lock:
|
| 56 |
return self._tracks.get(job_id, {}).get(frame_idx, [])
|
| 57 |
|
| 58 |
+
def get_track_summary(self, job_id: str) -> dict:
|
| 59 |
+
"""Return {frame_idx: track_count} for all stored frames."""
|
| 60 |
+
with self._lock:
|
| 61 |
+
frames = self._tracks.get(job_id, {})
|
| 62 |
+
return {idx: len(tracks) for idx, tracks in frames.items()}
|
| 63 |
+
|
| 64 |
def get(self, job_id: str) -> Optional[JobInfo]:
|
| 65 |
with self._lock:
|
| 66 |
return self._jobs.get(job_id)
|
|
|
|
| 104 |
|
| 105 |
def set_track_data(job_id: str, frame_idx: int, tracks: list) -> None:
|
| 106 |
get_job_storage().set_track_data(job_id, frame_idx, tracks)
|
| 107 |
+
|
| 108 |
+
def get_track_summary(job_id: str) -> dict:
|
| 109 |
+
return get_job_storage().get_track_summary(job_id)
|