Compare commits

...

2 Commits

Author SHA1 Message Date
05ce1f2cdb video_name missing in export 2026-07-06 19:47:31 +05:30
4d71e08aa4 detailed data for verifications 2026-07-02 18:48:15 +05:30
5 changed files with 122 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import {
api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, setAuthToken,
api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, passDeltas, setAuthToken,
type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow,
} from "./api";
import { RemarkCell } from "./RemarkCell";
@@ -505,15 +505,27 @@ export function App() {
{v.annotation_count > 0
? <a href={exportVideoUrl(v.id)} download title="Download this video's annotations (JSON)">{v.annotation_count} </a>
: 0}
{v.imported_count > 0 && (
<div className="imported-line" title="annotations present when verification started (imported baseline)">
<span className="dim">imported {v.imported_count}</span>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</div>
)}
{(v.imported_count > 0 || (v.passes?.length ?? 0) > 0) && (() => {
const deltas = passDeltas(v.imported_count, v.passes ?? []);
return (
<div className="imported-line" title="imported baseline → per-pass changes (verify / re-verify)">
<span className="dim">imported {v.imported_count}</span>
{deltas.length > 0
? deltas.map((d, i) => (
<span key={i} className={d.delta >= 0 ? "ann-add" : "ann-del"}
title={`${d.label} by ${d.username}${d.total} total`}>
{" · "}{d.label} {d.delta >= 0 ? "+" : ""}{d.delta}
</span>
))
: <>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</>}
</div>
);
})()}
</td>
<td>{v.review_count > 0
? <span className="review-flag" title={`${v.review_count} annotation(s) flagged for review`}>🚩 {v.review_count}</span>

View File

@@ -42,6 +42,14 @@ export interface Remark {
created_at: string;
}
/** One verification pass (a push). `count` is the annotation total after that pass;
* null for legacy pushes logged before we recorded it. */
export interface Pass {
username: string;
at: string;
count: number | null;
}
export interface VideoRow {
id: number;
file_name: string;
@@ -75,6 +83,7 @@ export interface VideoRow {
ignored: boolean;
ignored_by: string | null;
remarks: Remark[];
passes: Pass[]; // per-pass annotation-count history (push order)
}
export interface LeaderRow {
@@ -281,6 +290,25 @@ export const api = {
post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token),
};
/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, …
* Skips legacy passes that have no recorded count. The first pass is "verify",
* subsequent ones "re-verify". `total` is the running annotation count after the pass. */
export function passDeltas(
importedCount: number,
passes: Pass[],
): { label: string; delta: number; username: string; total: number }[] {
const out: { label: string; delta: number; username: string; total: number }[] = [];
let prev = importedCount;
let n = 0;
for (const p of passes ?? []) {
if (p.count == null) continue;
n += 1;
out.push({ label: n === 1 ? "verify" : "re-verify", delta: p.count - prev, username: p.username, total: p.count });
prev = p.count;
}
return out;
}
/** Human-readable byte size. */
export function fmtBytes(n: number): string {
if (!n || n <= 0) return "0 B";

View File

@@ -549,7 +549,14 @@ async fn list_videos(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPa
hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by,
COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at)
ORDER BY r.created_at)
FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks
FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks,
-- per-pass annotation-count history (each push, in order): lets the UI
-- show imported baseline → verify Δ → re-verify Δ. count is null for
-- legacy pushes logged before we recorded it.
COALESCE((SELECT json_agg(json_build_object(
'username', e.username, 'at', e.at,
'count', (e.meta->>'annotation_count')::int) ORDER BY e.at)
FROM video_events e WHERE e.video_id = videos.id AND e.event='push'), '[]'::json) AS passes
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#);
let rows = sqlx::query_as::<_, VideoRow>(&q)
.bind(id)
@@ -559,6 +566,31 @@ async fn list_videos(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPa
Ok(Json(rows))
}
/// Ensure an annotations export doc carries a `video` identity block. Some ingested /
/// pushed docs omit it, which makes a bulk export impossible to tie back to a file.
/// Injects `{ file_name, rel_path }` when absent; leaves an existing block untouched.
fn ensure_video_block(mut doc: serde_json::Value, file_name: &str, rel_path: &str) -> serde_json::Value {
if let Some(obj) = doc.as_object_mut() {
let has_video = obj.get("video").map(|v| !v.is_null()).unwrap_or(false);
if !has_video {
obj.insert("video".into(), json!({ "file_name": file_name, "rel_path": rel_path }));
}
}
doc
}
/// One entry in the bulk project export. Serialized as a struct (NOT the `json!` macro)
/// so the video identity fields come FIRST — serde_json orders `json!` object keys
/// alphabetically, which otherwise buries `file_name` far below the huge `annotations`
/// array and makes it look missing.
#[derive(serde::Serialize)]
struct ExportedVideo {
file_name: String,
rel_path: String,
video_id: i32,
annotations: serde_json::Value,
}
/// `GET /api/videos/:id/export` — download one video's annotations as the canonical
/// export JSON (`videos.raw_json`, the doc pushed by the worker / ingested from NAS).
/// Auth required (via `?token=`); served as a file attachment.
@@ -566,14 +598,19 @@ async fn export_video(State(s): State<AppState>, Query(q): Query<TokenQuery>, Ax
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
}
let row: Option<(String, Option<serde_json::Value>)> =
sqlx::query_as("SELECT file_name, raw_json FROM videos WHERE id=$1")
let row: Option<(String, String, Option<serde_json::Value>)> =
sqlx::query_as("SELECT file_name, rel_path, raw_json FROM videos WHERE id=$1")
.bind(id)
.fetch_optional(&s.db)
.await
.map_err(err)?;
let (file_name, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
let doc = raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] }));
let (file_name, rel_path, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
// Embed the video identity inside the doc so the file's name/path travel with it.
let doc = ensure_video_block(
raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
&file_name,
&rel_path,
);
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;
let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name);
let fname = format!("{stem}_annotations.json");
@@ -597,9 +634,18 @@ async fn export_project(State(s): State<AppState>, Query(q): Query<TokenQuery>,
.fetch_all(&s.db)
.await
.map_err(err)?;
let videos: Vec<serde_json::Value> = rows
let videos: Vec<ExportedVideo> = rows
.into_iter()
.map(|(vid, fname, rel, raw)| json!({ "video_id": vid, "file_name": fname, "rel_path": rel, "annotations": raw }))
.map(|(vid, fname, rel, raw)| {
// file_name first (struct order) AND embedded in the annotations doc, so the
// name is both immediately visible per entry and self-contained with the data.
let annotations = ensure_video_block(
raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
&fname,
&rel,
);
ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations }
})
.collect();
let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos });
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;

View File

@@ -157,6 +157,10 @@ pub struct VideoRow {
pub ignored_by: Option<String>,
// Remark thread (json array of {username, body, created_at}); '[]' when none.
pub remarks: serde_json::Value,
// Per-pass annotation-count history (json array of {username, at, count}, one per
// push in order); '[]' when never pushed. Drives the imported→verify→re-verify
// count breakdown in the UI.
pub passes: serde_json::Value,
}
/// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video.

View File

@@ -282,7 +282,11 @@ pub async fn push(
verify_time_ms = COALESCE(videos.verify_time_ms, 0) + $3,
annotation_count=$4, raw_json=$5,
primary_annotator=$6, annotation_time_ms=$7, annotated_at=now(),
imported_count = COALESCE(NULLIF($8, 0), videos.imported_count),
-- imported_count is the ORIGINAL auto-imported baseline (set at ingest).
-- Keep it fixed once set: verification/re-verification must NOT fold added
-- annotations into it (otherwise "imported 200" would drift to 210). Only
-- establish it from the claim-time count if it was never set.
imported_count = COALESCE(NULLIF(videos.imported_count, 0), $8),
claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL
WHERE id=$1"#,
)
@@ -299,7 +303,16 @@ pub async fn push(
.map_err(err)?;
tx.commit().await.map_err(err)?;
log_event(&s.db, id, &user.username, "push", json!({ "verify_time_ms": verify_time_ms })).await;
// Record this pass's resulting annotation count so the dashboard can show a
// per-pass breakdown (imported baseline → verify Δ → re-verify Δ …), not just a net.
log_event(
&s.db,
id,
&user.username,
"push",
json!({ "verify_time_ms": verify_time_ms, "annotation_count": ann_count }),
)
.await;
// Mark project activity + auto-resume its timer (a verify/re-verify restarts it).
let _ = sqlx::query(