Shoraky commited on
Commit
d1e2705
·
1 Parent(s): 2cfbff5

Store action playback times from final video frames

Browse files
Files changed (6) hide show
  1. Dockerfile +1 -1
  2. README.md +6 -0
  3. app.py +31 -4
  4. backfill_playback_times.py +81 -0
  5. playback_timing.py +38 -0
  6. src/analysis.ts +35 -6
Dockerfile CHANGED
@@ -17,7 +17,7 @@ COPY requirements.txt ./
17
  RUN pip install --no-cache-dir -r requirements.txt
18
  COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
19
  COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
20
- COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py ./
21
  USER appuser
22
  EXPOSE 7860
23
  CMD ["python", "start.py"]
 
17
  RUN pip install --no-cache-dir -r requirements.txt
18
  COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
19
  COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
20
+ COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py playback_timing.py backfill_playback_times.py ./
21
  USER appuser
22
  EXPOSE 7860
23
  CMD ["python", "start.py"]
README.md CHANGED
@@ -104,6 +104,12 @@ Brevo is configured as Supabase Auth's SMTP provider; the Express API does not s
104
 
105
  Brevo credentials stay in Supabase SMTP settings and are not added to the browser or committed to `.env`.
106
 
 
 
 
 
 
 
107
  ## Routes
108
 
109
  - `POST /api/auth/signup` — `{ fname, lname, email, password, academy? }`; sends Supabase's confirmation email when confirmation is enabled.
 
104
 
105
  Brevo credentials stay in Supabase SMTP settings and are not added to the browser or committed to `.env`.
106
 
107
+ ## Action playback timestamps
108
+
109
+ Run `Database/add_action_playback_times.sql` in the Supabase SQL Editor before relying on saved action playback times. New completed actions store the presentation timestamps of their first frame and the frame after their last frame from the finalized camera video. Older actions remain playable using the FPS calculation until backfilled.
110
+
111
+ To fill older completed sessions, run `python backfill_playback_times.py` from this directory with `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `STORAGE_REPO_ID`, and `HF_TOKEN` set in `Backend/.env` or the environment. Add `--session-id UUID` to process one session. The script downloads one camera video per session and updates only actions whose playback times are missing, so the first run can take time for large videos. Re-running it is safe.
112
+
113
  ## Routes
114
 
115
  - `POST /api/auth/signup` — `{ fname, lname, email, password, academy? }`; sends Supabase's confirmation email when confirmation is enabled.
app.py CHANGED
@@ -31,6 +31,7 @@ from fastapi.responses import FileResponse, JSONResponse
31
  from huggingface_hub import HfApi, hf_hub_download, snapshot_download
32
  from huggingface_hub.errors import RemoteEntryNotFoundError
33
  from storage_listing import session_files
 
34
 
35
  load_dotenv(Path(__file__).with_name(".env"))
36
  jobs = {}
@@ -438,7 +439,7 @@ def finalize_videos(work, cameras, job):
438
  "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-y", "-i", str(source),
439
  "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
440
  "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
441
- "-movflags", "+faststart", "-progress", "pipe:1", str(temporary),
442
  ]
443
  process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
444
  stderr_lines = []
@@ -518,13 +519,21 @@ def run_job(client_id, work, cameras):
518
  finalize_videos(work, cameras, job)
519
  if job.get("cancel"):
520
  raise InterruptedError()
 
 
 
 
 
521
  job.update(progress=98, phase="Saving results", stage="saving", videoProgress=100)
522
  hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
523
  folder_path=str(work), path_in_repo=f"sessions/{session_id}",
524
  allow_patterns=["results.json", "videos/*", "posters/*.webp"])
525
  saved = True
526
  completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
527
- {"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report}}
 
 
 
528
  for index, report in enumerate(reports)]}
529
  job["completion"] = completion
530
  backend(f"/{session_id}", job.get("ownerUserId") or job["userId"], "PUT", completion, is_admin=job.get("actorIsAdmin", False))
@@ -862,6 +871,14 @@ def update_action(session_id: UUID, action_id: UUID, payload: dict, request: Req
862
  and result_action_id(session_id, index) == str(action_id)), None)
863
  if matching_index is None:
864
  raise HTTPException(404, "Action not found")
 
 
 
 
 
 
 
 
865
  reports[matching_index]["start_frame"] = start_frame
866
  reports[matching_index]["end_frame"] = end_frame
867
  reports[matching_index]["action"] = action_type.strip()
@@ -870,6 +887,8 @@ def update_action(session_id: UUID, action_id: UUID, payload: dict, request: Req
870
  try:
871
  backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "PATCH", {
872
  "startFrame": start_frame, "endFrame": end_frame, "type": action_type.strip(),
 
 
873
  }, is_admin=True)
874
  except Exception:
875
  try:
@@ -916,6 +935,7 @@ def results(session_id: UUID, request: Request):
916
  camera_files.append((int(camera_id_text), filename))
917
  camera_files.sort()
918
  fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
 
919
  actions, failed = [], []
920
  for index, report in enumerate(reports):
921
  if report.get("_deleted"):
@@ -965,10 +985,17 @@ def results(session_id: UUID, request: Request):
965
  "poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
966
  for camera_id, filename in camera_files]
967
  action_id = result_action_id(session_id, index)
 
 
 
 
 
 
968
  actions.append({"id": action_id, "label": label,
969
  "start": str(start), "end": str(end), "fps": action_fps,
970
- "startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
971
- "endSeconds": (end + 1) / action_fps, "totalFrames": max(0, end - start + 1),
 
972
  "preFrames": 0, "inFrame": max(0, (end - start) // 2), "postFrames": 0,
973
  "cameraClips": clips, "sourceCameraClips": [], "preMetrics": [],
974
  "preActionMetrics": [], "inActionMetrics": metrics, "postMetrics": [],
 
31
  from huggingface_hub import HfApi, hf_hub_download, snapshot_download
32
  from huggingface_hub.errors import RemoteEntryNotFoundError
33
  from storage_listing import session_files
34
+ from playback_timing import action_times, frame_times
35
 
36
  load_dotenv(Path(__file__).with_name(".env"))
37
  jobs = {}
 
439
  "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-y", "-i", str(source),
440
  "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
441
  "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
442
+ "-fps_mode", "passthrough", "-movflags", "+faststart", "-progress", "pipe:1", str(temporary),
443
  ]
444
  process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
445
  stderr_lines = []
 
519
  finalize_videos(work, cameras, job)
520
  if job.get("cancel"):
521
  raise InterruptedError()
522
+ try:
523
+ playback_boundaries = frame_times(work / "videos" / f"camera_{min(cameras)}.mp4")
524
+ except (OSError, ValueError, subprocess.CalledProcessError):
525
+ logger.exception("Unable to index final video timestamps for session %s", session_id)
526
+ playback_boundaries = None
527
  job.update(progress=98, phase="Saving results", stage="saving", videoProgress=100)
528
  hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
529
  folder_path=str(work), path_in_repo=f"sessions/{session_id}",
530
  allow_patterns=["results.json", "videos/*", "posters/*.webp"])
531
  saved = True
532
  completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
533
+ {"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report},
534
+ **(action_times(playback_boundaries, int(report["start_frame"]), int(report["end_frame"]))
535
+ if playback_boundaries and not report.get("error")
536
+ and int(report["end_frame"]) + 1 < len(playback_boundaries) else {})}
537
  for index, report in enumerate(reports)]}
538
  job["completion"] = completion
539
  backend(f"/{session_id}", job.get("ownerUserId") or job["userId"], "PUT", completion, is_admin=job.get("actorIsAdmin", False))
 
871
  and result_action_id(session_id, index) == str(action_id)), None)
872
  if matching_index is None:
873
  raise HTTPException(404, "Action not found")
874
+ video_dir = f'{row["path"]}/videos/'
875
+ video_files = sorted(file for file in session_files(hub(), os.environ["STORAGE_REPO_ID"], video_dir.rstrip("/"))
876
+ if re.search(r"/camera_\d+\.mp4$", file))
877
+ if not video_files:
878
+ raise HTTPException(409, "Session video is unavailable")
879
+ video_path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
880
+ filename=video_files[0], token=os.environ["HF_TOKEN"])
881
+ playback_times = action_times(frame_times(video_path), start_frame, end_frame)
882
  reports[matching_index]["start_frame"] = start_frame
883
  reports[matching_index]["end_frame"] = end_frame
884
  reports[matching_index]["action"] = action_type.strip()
 
887
  try:
888
  backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "PATCH", {
889
  "startFrame": start_frame, "endFrame": end_frame, "type": action_type.strip(),
890
+ "startTimeSeconds": playback_times["start_time_seconds"],
891
+ "endTimeSeconds": playback_times["end_time_seconds"],
892
  }, is_admin=True)
893
  except Exception:
894
  try:
 
935
  camera_files.append((int(camera_id_text), filename))
936
  camera_files.sort()
937
  fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
938
+ saved_times = {item["action_id"]: item for item in row.get("action_times", [])}
939
  actions, failed = [], []
940
  for index, report in enumerate(reports):
941
  if report.get("_deleted"):
 
985
  "poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
986
  for camera_id, filename in camera_files]
987
  action_id = result_action_id(session_id, index)
988
+ playback = saved_times.get(action_id) or {}
989
+ start_seconds = playback.get("start_time_seconds")
990
+ end_seconds = playback.get("end_time_seconds")
991
+ has_playback_times = (isinstance(start_seconds, (int, float)) and isinstance(end_seconds, (int, float))
992
+ and math.isfinite(start_seconds) and math.isfinite(end_seconds)
993
+ and start_seconds >= 0 and end_seconds > start_seconds)
994
  actions.append({"id": action_id, "label": label,
995
  "start": str(start), "end": str(end), "fps": action_fps,
996
+ "startFrame": start, "endFrame": end, "startSeconds": start_seconds if has_playback_times else start / action_fps,
997
+ "endSeconds": end_seconds if has_playback_times else (end + 1) / action_fps,
998
+ "hasPlaybackTimes": has_playback_times, "totalFrames": max(0, end - start + 1),
999
  "preFrames": 0, "inFrame": max(0, (end - start) // 2), "postFrames": 0,
1000
  "cameraClips": clips, "sourceCameraClips": [], "preMetrics": [],
1001
  "preActionMetrics": [], "inActionMetrics": metrics, "postMetrics": [],
backfill_playback_times.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fill action playback times from the stored final MP4 videos.
2
+
3
+ Run after Database/add_action_playback_times.sql:
4
+ python backfill_playback_times.py [--session-id UUID]
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+ from dotenv import load_dotenv
14
+ from huggingface_hub import HfApi, hf_hub_download
15
+
16
+ from playback_timing import action_times, frame_times
17
+ from storage_listing import session_files
18
+
19
+
20
+ def main():
21
+ load_dotenv(Path(__file__).with_name(".env"))
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("--session-id", help="Backfill one session only")
24
+ args = parser.parse_args()
25
+
26
+ url = os.environ["SUPABASE_URL"].rstrip("/") + "/rest/v1"
27
+ key = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
28
+ repo = os.environ["STORAGE_REPO_ID"]
29
+ token = os.environ["HF_TOKEN"]
30
+ hub = HfApi(token=token)
31
+ headers = {"apikey": key, "Authorization": f"Bearer {key}"}
32
+ actions = []
33
+ with httpx.Client(headers=headers, timeout=60) as client:
34
+ offset = 0
35
+ while True:
36
+ params = {"select": "action_id,session_id,start_frame,end_frame", "start_time_seconds": "is.null",
37
+ "order": "action_id", "limit": "1000", "offset": str(offset)}
38
+ if args.session_id:
39
+ params["session_id"] = f"eq.{args.session_id}"
40
+ response = client.get(f"{url}/actions", params=params)
41
+ response.raise_for_status()
42
+ page = response.json()
43
+ actions.extend(page)
44
+ if len(page) < 1000:
45
+ break
46
+ offset += len(page)
47
+
48
+ grouped = defaultdict(list)
49
+ for action in actions:
50
+ grouped[action["session_id"]].append(action)
51
+ saved = 0
52
+ for session_id, session_actions in grouped.items():
53
+ try:
54
+ response = client.get(f"{url}/sessions", params={"select": "path,status", "session_id": f"eq.{session_id}"})
55
+ response.raise_for_status()
56
+ sessions = response.json()
57
+ if len(sessions) != 1 or sessions[0]["status"] != "completed" or sessions[0]["path"] != f"sessions/{session_id}":
58
+ print(f"Skipping {session_id}: completed session storage unavailable")
59
+ continue
60
+ video_dir = f"sessions/{session_id}/videos"
61
+ files = sorted(file for file in session_files(hub, repo, video_dir)
62
+ if Path(file).name.startswith("camera_") and Path(file).suffix.lower() == ".mp4")
63
+ if not files:
64
+ print(f"Skipping {session_id}: no MP4 video")
65
+ continue
66
+ video = hf_hub_download(repo_id=repo, repo_type="dataset", filename=files[0], token=token)
67
+ boundaries = frame_times(video)
68
+ for action in session_actions:
69
+ times = action_times(boundaries, action["start_frame"], action["end_frame"])
70
+ response = client.patch(f"{url}/actions", params={"action_id": f'eq.{action["action_id"]}',
71
+ "start_time_seconds": "is.null"}, json=times)
72
+ response.raise_for_status()
73
+ saved += 1
74
+ print(f"Saved {len(session_actions)} actions for {session_id}")
75
+ except Exception as error:
76
+ print(f"Failed {session_id}: {error}")
77
+ print(f"Done: {saved}/{len(actions)} action rows updated")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
playback_timing.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read action playback boundaries from the presentation timestamps of a final video."""
2
+
3
+ import json
4
+ import math
5
+ import subprocess
6
+
7
+
8
+ def frame_times(video_path):
9
+ result = subprocess.run(
10
+ ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_frames",
11
+ "-show_entries", "frame=best_effort_timestamp_time,duration_time,pkt_duration_time",
12
+ "-of", "json", str(video_path)],
13
+ check=True, capture_output=True, text=True,
14
+ )
15
+ frames = json.loads(result.stdout).get("frames", [])
16
+ try:
17
+ times = [float(frame["best_effort_timestamp_time"]) for frame in frames]
18
+ except (KeyError, TypeError, ValueError) as error:
19
+ raise ValueError(f"Missing video frame timestamps: {video_path}") from error
20
+ if not times or not all(math.isfinite(value) and value >= 0 for value in times):
21
+ raise ValueError(f"Invalid video frame timestamps: {video_path}")
22
+ if any(after <= before for before, after in zip(times, times[1:])):
23
+ raise ValueError(f"Non-increasing video frame timestamps: {video_path}")
24
+ last = frames[-1]
25
+ duration = last.get("duration_time") or last.get("pkt_duration_time")
26
+ duration = float(duration) if duration is not None else 0
27
+ if duration <= 0 and len(times) > 1:
28
+ duration = times[-1] - times[-2]
29
+ if not math.isfinite(duration) or duration <= 0:
30
+ raise ValueError(f"Missing final frame duration: {video_path}")
31
+ return times + [times[-1] + duration]
32
+
33
+
34
+ def action_times(boundaries, start_frame, end_frame):
35
+ if start_frame < 0 or end_frame < start_frame or end_frame + 1 >= len(boundaries):
36
+ raise ValueError(f"Action frame range {start_frame}-{end_frame} is outside video")
37
+ return {"start_time_seconds": boundaries[start_frame],
38
+ "end_time_seconds": boundaries[end_frame + 1]}
src/analysis.ts CHANGED
@@ -13,6 +13,8 @@ const beginSchema = z.object({ clientId: z.uuid(), playerId: z.uuid(), name: z.s
13
  const reportSchema = z.object({
14
  index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
15
  start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
 
 
16
  error: z.string().max(2000).optional(),
17
  });
18
  export const completionSchema = z.discriminatedUnion("status", [
@@ -23,10 +25,14 @@ export const completionSchema = z.discriminatedUnion("status", [
23
  export function actionRows(sessionId: string, reports: z.infer<typeof reportSchema>[]) {
24
  if (new Set(reports.map((report) => report.index)).size !== reports.length) throw new Error("Duplicate action index");
25
  return reports.filter((report) => report.error === undefined).map((report) => {
26
- const { start_frame: startFrame, end_frame: endFrame } = report;
 
27
  if (startFrame === undefined || endFrame === undefined || endFrame < startFrame) throw new Error("Invalid action interval");
 
 
28
  return { action_id: stableId(`${sessionId}:${report.index}`), type: report.action,
29
- start_frame: startFrame, end_frame: endFrame };
 
30
  });
31
  }
32
 
@@ -111,7 +117,13 @@ export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?:
111
  if (res.locals.isAdmin !== true) query = query.eq("user_id", res.locals.userId);
112
  const { data, error } = await query.maybeSingle();
113
  if (error) return res.status(503).json({ error: "Unable to load session" });
114
- return data ? res.json(data) : res.sendStatus(404);
 
 
 
 
 
 
115
  } catch (error) { next(error); }
116
  });
117
  router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
@@ -142,15 +154,27 @@ export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?:
142
  startFrame: z.number().int().nonnegative(),
143
  endFrame: z.number().int().nonnegative(),
144
  type: z.string().trim().min(1).max(50),
 
 
145
  }).strict().parse(req.body);
146
  if (input.endFrame < input.startFrame) return res.status(400).json({ error: "Action end frame must be after its start frame." });
 
147
  const { data: session } = await db.from("sessions").select("status").eq("session_id", sessionId).maybeSingle();
148
  if (!session || ["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be edited while analysis is running." });
149
  const { data, error } = await db.from("actions").update({
150
  start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
 
151
  }).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
152
- if (error) return res.status(503).json({ error: "Unable to update action." });
153
- if (!data) return res.sendStatus(404);
 
 
 
 
 
 
 
 
154
  return res.json({ ok: true });
155
  } catch (error) { next(error); }
156
  });
@@ -195,7 +219,12 @@ export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?:
195
  const rows = actions.map((action) => ({ ...action, session_id: sessionId }));
196
  if (rows.length) {
197
  const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
198
- if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
 
 
 
 
 
199
  }
200
  if (onCompleted && session.user_id) void onCompleted(sessionId, session.user_id).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
201
  }
 
13
  const reportSchema = z.object({
14
  index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
15
  start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
16
+ start_time_seconds: z.number().finite().nonnegative().optional(),
17
+ end_time_seconds: z.number().finite().positive().optional(),
18
  error: z.string().max(2000).optional(),
19
  });
20
  export const completionSchema = z.discriminatedUnion("status", [
 
25
  export function actionRows(sessionId: string, reports: z.infer<typeof reportSchema>[]) {
26
  if (new Set(reports.map((report) => report.index)).size !== reports.length) throw new Error("Duplicate action index");
27
  return reports.filter((report) => report.error === undefined).map((report) => {
28
+ const { start_frame: startFrame, end_frame: endFrame,
29
+ start_time_seconds: startTime, end_time_seconds: endTime } = report;
30
  if (startFrame === undefined || endFrame === undefined || endFrame < startFrame) throw new Error("Invalid action interval");
31
+ if ((startTime === undefined) !== (endTime === undefined) ||
32
+ (startTime !== undefined && endTime !== undefined && endTime <= startTime)) throw new Error("Invalid action playback times");
33
  return { action_id: stableId(`${sessionId}:${report.index}`), type: report.action,
34
+ start_frame: startFrame, end_frame: endFrame,
35
+ start_time_seconds: startTime ?? null, end_time_seconds: endTime ?? null };
36
  });
37
  }
38
 
 
117
  if (res.locals.isAdmin !== true) query = query.eq("user_id", res.locals.userId);
118
  const { data, error } = await query.maybeSingle();
119
  if (error) return res.status(503).json({ error: "Unable to load session" });
120
+ if (!data) return res.sendStatus(404);
121
+ const { data: actionTimes, error: timesError } = await db.from("actions")
122
+ .select("action_id,start_time_seconds,end_time_seconds").eq("session_id", sessionId);
123
+ if (timesError) {
124
+ console.error("Unable to load action playback times", { sessionId, code: timesError.code, message: timesError.message });
125
+ }
126
+ return res.json({ ...data, action_times: actionTimes ?? [] });
127
  } catch (error) { next(error); }
128
  });
129
  router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
 
154
  startFrame: z.number().int().nonnegative(),
155
  endFrame: z.number().int().nonnegative(),
156
  type: z.string().trim().min(1).max(50),
157
+ startTimeSeconds: z.number().finite().nonnegative(),
158
+ endTimeSeconds: z.number().finite().positive(),
159
  }).strict().parse(req.body);
160
  if (input.endFrame < input.startFrame) return res.status(400).json({ error: "Action end frame must be after its start frame." });
161
+ if (input.endTimeSeconds <= input.startTimeSeconds) return res.status(400).json({ error: "Action playback interval is invalid." });
162
  const { data: session } = await db.from("sessions").select("status").eq("session_id", sessionId).maybeSingle();
163
  if (!session || ["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be edited while analysis is running." });
164
  const { data, error } = await db.from("actions").update({
165
  start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
166
+ start_time_seconds: input.startTimeSeconds, end_time_seconds: input.endTimeSeconds,
167
  }).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
168
+ if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to update action." });
169
+ let updated = data;
170
+ if (error) {
171
+ const fallback = await db.from("actions").update({
172
+ start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
173
+ }).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
174
+ if (fallback.error) return res.status(503).json({ error: "Unable to update action." });
175
+ updated = fallback.data;
176
+ }
177
+ if (!updated) return res.sendStatus(404);
178
  return res.json({ ok: true });
179
  } catch (error) { next(error); }
180
  });
 
219
  const rows = actions.map((action) => ({ ...action, session_id: sessionId }));
220
  if (rows.length) {
221
  const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
222
+ if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to save analysis actions." });
223
+ if (error) {
224
+ const legacyRows = rows.map(({ start_time_seconds: _start, end_time_seconds: _end, ...row }) => row);
225
+ const fallback = await db.from("actions").upsert(legacyRows, { onConflict: "action_id" });
226
+ if (fallback.error) return res.status(503).json({ error: "Unable to save analysis actions." });
227
+ }
228
  }
229
  if (onCompleted && session.user_id) void onCompleted(sessionId, session.user_id).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
230
  }