Files
Artemis-Eapil/artemis/utils/media.py
T
somew1nd 54fcef9887 refactor: clean up legacy code, rename hopper to entity_extractor, and add third-party notices
- Add NOTICE file and README license references for mobile-use and finalrun-agent
- Add derivative attribution headers to 17 files derived from mobile-use
- Rename hopper agent to entity_extractor across prompts, tools, and configs
- Remove obsolete adb_tunnel, ApiBaseUrl, and legacy CLI utility scripts
- Replace legacy examples with native Artemis diagnostic and workflow examples
- Update unit and tool tests to validate entity_extractor and SDK builders
2026-09-11 16:19:42 -07:00

212 lines
6.6 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Media synthesis and trace asset management for ARTEMIS.
Handles chronological trace GIF generation, frame quantization, and step snapshot
compilation while preserving active session recording manifests.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
from typing import Any
from PIL import Image
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
_PREFER_FFMPEG = os.environ.get("USE_FFMPEG_GIF", "").strip().lower() in ("1", "true", "yes")
def quantize_and_save_gif_from_paths(
image_paths: list[Path],
output_path: Path,
colors: int = 128,
duration: int = 100,
) -> None:
"""Compile ordered image snapshots into a quantized animation asset."""
if not image_paths:
raise ValueError("Cannot assemble animation from an empty image sequence.")
if _PREFER_FFMPEG and shutil.which("ffmpeg"):
try:
_render_gif_with_ffmpeg(image_paths, output_path, colors=colors, frame_delay_ms=duration)
return
except Exception as exc:
logger.warning(f"FFmpeg GIF rendering encountered an error ({exc}); falling back to Pillow.")
_render_gif_with_pillow(image_paths, output_path, colors=colors, frame_delay_ms=duration)
def _render_gif_with_pillow(
paths: list[Path],
output_path: Path,
colors: int,
frame_delay_ms: int,
) -> None:
"""Internal Pillow quantization loop."""
frames: list[Image.Image] = []
for p in paths:
try:
with Image.open(p) as img:
rgb_img = img.convert("RGB") if img.mode != "RGB" else img.copy()
frames.append(rgb_img.quantize(colors=colors, method=Image.Quantize.MEDIANCUT))
except Exception as exc:
logger.debug(f"Skipping damaged frame {p}: {exc}")
if not frames:
return
first_frame = frames[0]
first_frame.save(
output_path,
save_all=True,
append_images=frames[1:],
loop=0,
optimize=True,
duration=frame_delay_ms,
)
def _render_gif_with_ffmpeg(
paths: list[Path],
output_path: Path,
colors: int,
frame_delay_ms: int,
) -> None:
"""Streamlined two-pass palette-generated GIF rendering via FFmpeg."""
fps = max(1.0, 1000.0 / max(1, frame_delay_ms))
with tempfile.TemporaryDirectory() as tmpdir:
list_file = Path(tmpdir) / "frames.txt"
palette = Path(tmpdir) / "palette.png"
lines = [f"file '{p.resolve()}'\nduration {frame_delay_ms / 1000.0}" for p in paths]
if paths:
lines.append(f"file '{paths[-1].resolve()}'")
list_file.write_text("\n".join(lines), encoding="utf-8")
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-vf",
f"palettegen=max_colors={min(colors, 256)}",
str(palette),
],
capture_output=True,
check=True,
)
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-i",
str(palette),
"-lavfi",
f"fps={fps},paletteuse",
str(output_path),
],
capture_output=True,
check=True,
)
def create_gif_from_trace_folder(trace_folder_path: Path) -> None:
"""Scan and synthesize sequential JPEG frames in a trace directory to trace.gif."""
if not trace_folder_path.is_dir():
return
frames = sorted(
[f for f in trace_folder_path.iterdir() if f.suffix.lower() in (".jpeg", ".jpg") and f.stem.isdigit()],
key=lambda x: int(x.stem),
)
if not frames:
return
target_gif = trace_folder_path / "trace.gif"
quantize_and_save_gif_from_paths(frames, target_gif)
logger.info(f"Assembled trace animation: {target_gif}")
def remove_images_from_trace_folder(trace_folder_path: Path) -> None:
"""Purge temporary individual JPEG frame captures once compiled."""
if not trace_folder_path.is_dir():
return
for item in trace_folder_path.iterdir():
if item.suffix.lower() in (".jpeg", ".jpg") and item.stem.isdigit():
try:
item.unlink(missing_ok=True)
except OSError:
pass
def create_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
"""Collate discrete numeric step payload dumps into steps.json preserving metadata manifests."""
if not trace_folder_path.is_dir():
return
numeric_step_files = sorted(
[f for f in trace_folder_path.iterdir() if f.suffix == ".json" and f.stem.isdigit()],
key=lambda x: int(x.stem),
)
records: list[dict[str, Any]] = []
for step_file in numeric_step_files:
try:
records.append({
"timestamp": int(step_file.stem),
"data": step_file.read_text(encoding="utf-8", errors="ignore"),
})
except Exception as exc:
logger.debug(f"Unable to read step file {step_file}: {exc}")
output_file = trace_folder_path / "steps.json"
output_file.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
def remove_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
"""Clean up standalone numeric step files, leaving steps.json and recording manifests untouched."""
if not trace_folder_path.is_dir():
return
for item in trace_folder_path.iterdir():
if item.suffix == ".json" and item.stem.isdigit():
try:
item.unlink(missing_ok=True)
except OSError:
pass