fix: finalize recordings before automatic replay
This commit is contained in:
1 parent
474e56ef8e
commit
a62ac4aef1
25 files changed
+820
-92
No files matched your search
@@ -476,6 +476,16 @@ class UnifiedMobileController:
|
||||
asyncio.create_task(self._remux_segment_record(session.android_segment_records[-1]))
|
||||
)
|
||||
|
||||
def _record_recording_failure(self, session: RecordingSession, message: str) -> None:
|
||||
if self.ctx and self.ctx.data_engine:
|
||||
self.ctx.data_engine.record_video_failure(
|
||||
video_id=session.video_id,
|
||||
device_id=session.device_id,
|
||||
local_video_path=session.local_video_path,
|
||||
start_time=session.start_time,
|
||||
error=message,
|
||||
)
|
||||
|
||||
async def _start_next_recording_segment(
|
||||
self, session: RecordingSession, display_state: tuple[int, int, int] | None
|
||||
) -> bool:
|
||||
@@ -612,6 +622,7 @@ class UnifiedMobileController:
|
||||
stderr = await process.stderr.read()
|
||||
err_msg = stderr.decode()
|
||||
logger.error(f"scrcpy failed to start on {device_id}: {err_msg}")
|
||||
self._record_recording_failure(session, f"scrcpy failed to start: {err_msg}")
|
||||
remove_active_session(device_id)
|
||||
return VideoRecordingResult(
|
||||
success=False,
|
||||
@@ -675,10 +686,12 @@ class UnifiedMobileController:
|
||||
|
||||
output_path = session.local_video_path
|
||||
if not output_path or not output_path.exists():
|
||||
message = "Recording file not found on disk"
|
||||
self._record_recording_failure(session, message)
|
||||
remove_active_session(device_id)
|
||||
return VideoRecordingResult(
|
||||
success=False,
|
||||
message="Recording file not found on disk",
|
||||
message=message,
|
||||
)
|
||||
|
||||
self._finalize_current_segment(session, time.time())
|
||||
@@ -690,17 +703,31 @@ class UnifiedMobileController:
|
||||
self._remux_segment_record(record)
|
||||
)
|
||||
)
|
||||
conversion_results = []
|
||||
if session.android_conversion_tasks:
|
||||
await asyncio.gather(*session.android_conversion_tasks, return_exceptions=True)
|
||||
conversion_results = await asyncio.gather(
|
||||
*session.android_conversion_tasks, return_exceptions=True
|
||||
)
|
||||
mp4_paths = [
|
||||
Path(record["output_path"])
|
||||
for record in session.android_segment_records
|
||||
if Path(record["output_path"]).exists()
|
||||
]
|
||||
await write_recording_manifest(output_path.parent, mp4_paths)
|
||||
manifest_path = await write_recording_manifest(output_path.parent, mp4_paths)
|
||||
|
||||
conversion_failed = (
|
||||
len(mp4_paths) != len(session.android_segment_records)
|
||||
or any(result is not True for result in conversion_results)
|
||||
or manifest_path is None
|
||||
)
|
||||
if conversion_failed:
|
||||
message = "Recording finalization failed; no complete browser-safe video was produced"
|
||||
self._record_recording_failure(session, message)
|
||||
remove_active_session(device_id)
|
||||
return VideoRecordingResult(success=False, message=message)
|
||||
|
||||
remove_active_session(device_id)
|
||||
final_video_path = mp4_paths[0] if mp4_paths else output_path
|
||||
final_video_path = mp4_paths[0]
|
||||
|
||||
# Persist update to local database if Data Engine is active
|
||||
if self.ctx and self.ctx.data_engine:
|
||||
@@ -712,21 +739,15 @@ class UnifiedMobileController:
|
||||
end_time=time.time(),
|
||||
)
|
||||
|
||||
if mp4_paths:
|
||||
return VideoRecordingResult(
|
||||
success=True,
|
||||
message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments",
|
||||
video_path=mp4_paths[0],
|
||||
)
|
||||
else:
|
||||
return VideoRecordingResult(
|
||||
success=True,
|
||||
message=f"Recording stopped, saved as MKV to {output_path}",
|
||||
video_path=output_path,
|
||||
)
|
||||
return VideoRecordingResult(
|
||||
success=True,
|
||||
message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments",
|
||||
video_path=mp4_paths[0],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop scrcpy recording: {e}")
|
||||
self._record_recording_failure(session, str(e))
|
||||
remove_active_session(device_id)
|
||||
return VideoRecordingResult(
|
||||
success=False,
|
||||
|
||||
@@ -265,6 +265,9 @@ class DataEngine:
|
||||
session_id = self.current_session_id
|
||||
end_time = time.time()
|
||||
session = self.storage.get_session(session_id)
|
||||
if session and session.end_time is not None and session.status not in ("running", "paused"):
|
||||
logger.debug(f"Session end already published for {session_id}; skipping duplicate")
|
||||
return
|
||||
if session:
|
||||
session.end_time = end_time
|
||||
session.status = status
|
||||
@@ -328,11 +331,55 @@ class DataEngine:
|
||||
start_time=start_time,
|
||||
end_time=end_time or time.time(),
|
||||
local_video_path=str(local_video_path),
|
||||
status="ready",
|
||||
)
|
||||
self.storage.update_video_recording(record)
|
||||
self._publish(
|
||||
"recording_ready",
|
||||
{
|
||||
"session_id": str(self.current_session_id),
|
||||
"video_id": str(video_id),
|
||||
"local_video_path": str(local_video_path),
|
||||
"end_time": record.end_time,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record video stop in DataEngine: {e}")
|
||||
|
||||
def record_video_failure(
|
||||
self,
|
||||
video_id: UUID,
|
||||
device_id: str,
|
||||
local_video_path: str | Path | None,
|
||||
start_time: float,
|
||||
error: str,
|
||||
):
|
||||
"""Persist and publish a terminal recording failure."""
|
||||
if not self.storage:
|
||||
return
|
||||
try:
|
||||
record = VideoRecordingRecord(
|
||||
video_id=video_id,
|
||||
session_id=self.current_session_id,
|
||||
device_id=device_id,
|
||||
start_time=start_time,
|
||||
end_time=time.time(),
|
||||
local_video_path=str(local_video_path) if local_video_path else None,
|
||||
status="failed",
|
||||
error=error,
|
||||
)
|
||||
self.storage.update_video_recording(record)
|
||||
self._publish(
|
||||
"recording_failed",
|
||||
{
|
||||
"session_id": str(self.current_session_id),
|
||||
"video_id": str(video_id),
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record video failure in DataEngine: {e}")
|
||||
|
||||
def update_video_path(self, local_video_path: str | Path):
|
||||
"""Update the video path across tables when traces or videos are moved."""
|
||||
if not self.storage or not self.current_session_id:
|
||||
|
||||
@@ -100,3 +100,5 @@ class VideoRecordingRecord(BaseModel):
|
||||
start_time: float
|
||||
end_time: float | None = None
|
||||
local_video_path: str | None = None
|
||||
status: str = "recording"
|
||||
error: str | None = None
|
||||
@@ -213,9 +213,24 @@ class StorageManager:
|
||||
start_time REAL,
|
||||
end_time REAL,
|
||||
local_video_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'recording',
|
||||
error TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||||
)
|
||||
""")
|
||||
video_recording_columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(video_recordings)").fetchall()
|
||||
}
|
||||
if "status" not in video_recording_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE video_recordings ADD COLUMN status TEXT "
|
||||
"NOT NULL DEFAULT 'recording'"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE video_recordings SET status = 'ready' WHERE end_time IS NOT NULL"
|
||||
)
|
||||
if "error" not in video_recording_columns:
|
||||
conn.execute("ALTER TABLE video_recordings ADD COLUMN error TEXT")
|
||||
try:
|
||||
conn.execute("ALTER TABLE background_tasks ADD COLUMN logs TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -273,8 +288,11 @@ class StorageManager:
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO video_recordings (
|
||||
video_id, session_id, device_id, start_time, end_time,
|
||||
local_video_path, status, error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(record.video_id),
|
||||
@@ -283,6 +301,8 @@ class StorageManager:
|
||||
record.start_time,
|
||||
record.end_time,
|
||||
record.local_video_path,
|
||||
record.status,
|
||||
record.error,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -293,12 +313,14 @@ class StorageManager:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE video_recordings
|
||||
SET end_time = ?, local_video_path = ?
|
||||
SET end_time = ?, local_video_path = ?, status = ?, error = ?
|
||||
WHERE video_id = ?
|
||||
""",
|
||||
(
|
||||
record.end_time,
|
||||
record.local_video_path,
|
||||
record.status,
|
||||
record.error,
|
||||
str(record.video_id),
|
||||
),
|
||||
)
|
||||
@@ -345,6 +367,10 @@ class StorageManager:
|
||||
start_time=row["start_time"],
|
||||
end_time=row["end_time"],
|
||||
local_video_path=row["local_video_path"],
|
||||
status=row["status"] if "status" in row.keys() else (
|
||||
"ready" if row["end_time"] is not None else "recording"
|
||||
),
|
||||
error=row["error"] if "error" in row.keys() else None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
+12
-1
@@ -642,7 +642,7 @@ class Agent:
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await self._finalize_tracing(task=task, context=context)
|
||||
await self._finalize_tracing_safely(task=task, context=context)
|
||||
finally:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
await asyncio.to_thread(device_lock.release)
|
||||
@@ -833,6 +833,17 @@ class Agent:
|
||||
device_data["profile"] = task.request.profile
|
||||
context.data_engine.start_session(goal=task.request.goal, device_info=device_data)
|
||||
|
||||
async def _finalize_tracing_safely(self, task: Task, context: ArtemisContext):
|
||||
"""Finalize optional trace artifacts without changing task semantics."""
|
||||
try:
|
||||
await self._finalize_tracing(task=task, context=context)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"[{task.get_name()}] Trace artifact finalization failed after task status "
|
||||
f"was resolved as '{task.status}': {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
||||
if context.data_engine:
|
||||
session_status = "completed"
|
||||
|
||||
@@ -186,9 +186,10 @@ def remove_images_from_trace_folder(trace_folder_path: Path):
|
||||
|
||||
|
||||
def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||
"""Compile legacy timestamp-named step files without consuming manifests."""
|
||||
steps = []
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".json":
|
||||
if file.suffix == ".json" and file.stem.isdigit():
|
||||
with open(file, encoding="utf-8", errors="ignore") as f:
|
||||
json_content = f.read()
|
||||
steps.append({"timestamp": int(file.stem), "data": json_content})
|
||||
@@ -202,6 +203,7 @@ def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||
|
||||
|
||||
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||
"""Remove only legacy timestamp-named step files, preserving manifests."""
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".json" and file.name != "steps.json":
|
||||
if file.suffix == ".json" and file.stem.isdigit():
|
||||
file.unlink()
|
||||
+23
-8
@@ -256,9 +256,12 @@ async def normalize_recording_to_mp4(
|
||||
|
||||
|
||||
async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
||||
"""Move a fixed-dimension recording segment into MP4 without re-encoding."""
|
||||
"""Atomically publish a fixed-dimension recording segment as MP4."""
|
||||
if not source_path.exists() or source_path.stat().st_size == 0:
|
||||
return False
|
||||
temporary_path = output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
get_ffmpeg_path(),
|
||||
@@ -275,13 +278,18 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
str(temporary_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_stdout, stderr = await process.communicate()
|
||||
valid = process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0
|
||||
valid = (
|
||||
process.returncode == 0
|
||||
and temporary_path.exists()
|
||||
and temporary_path.stat().st_size > 0
|
||||
)
|
||||
if valid:
|
||||
temporary_path.replace(output_path)
|
||||
return True
|
||||
logger.error(
|
||||
f"Failed to remux recording segment (code {process.returncode}): "
|
||||
@@ -289,8 +297,8 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remux recording segment: {e}")
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
return False
|
||||
|
||||
|
||||
@@ -363,26 +371,33 @@ async def write_recording_manifest(output_dir: Path, mp4_paths: list[Path]) -> P
|
||||
continue
|
||||
metadata = await probe_video_segment(path)
|
||||
duration = float(metadata.get("duration", 0))
|
||||
width = int(metadata.get("width", 0))
|
||||
height = int(metadata.get("height", 0))
|
||||
if duration <= 0 or width <= 0 or height <= 0:
|
||||
logger.error(f"Recording segment failed validation: {path}")
|
||||
return None
|
||||
segments.append(
|
||||
{
|
||||
"file": path.name,
|
||||
"start": round(timeline, 3),
|
||||
"duration": round(duration, 3),
|
||||
"width": int(metadata.get("width", 0)),
|
||||
"height": int(metadata.get("height", 0)),
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
timeline += duration
|
||||
if not segments:
|
||||
return None
|
||||
manifest_path = output_dir / "recording.json"
|
||||
manifest_path.write_text(
|
||||
temporary_manifest_path = output_dir / "recording.part.json"
|
||||
temporary_manifest_path.write_text(
|
||||
json.dumps(
|
||||
{"version": 1, "duration": round(timeline, 3), "segments": segments},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_manifest_path.replace(manifest_path)
|
||||
return manifest_path
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user