Spaces:
Running
Running
Store action playback times from final video frames
Browse files- Dockerfile +1 -1
- README.md +6 -0
- app.py +31 -4
- backfill_playback_times.py +81 -0
- playback_timing.py +38 -0
- 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,
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|