fix: adjust action validation and preserve recording timeline gaps
This commit is contained in:
1 parent
2fea9865ee
commit
ab03e1d2bb
15 files changed
+235
-401
No files matched your search
@@ -274,7 +274,7 @@ Evaluated on [AndroidWorld](https://github.com/google-research/android_world)
|
|||||||
ARTEMIS supports two execution profiles tailored for different automation requirements:
|
ARTEMIS supports two execution profiles tailored for different automation requirements:
|
||||||
|
|
||||||
* **Flash Profile (`--profile flash`)**: Fast and token-efficient reactive loop (~3–5s per step): one model observes the live screen, thinks, and acts, with no graph orchestration. Ideal for routine, deterministic UI tasks. The loop is unbounded by default (`agent.flash.max_turns`, 0 = unlimited) because history is compressed rather than capped: Flash shares the Pro session transcript ledger (session-relative `T+mm:ss` clock, screenshots folded into visual summaries, older steps chunked into eras and recallable on demand via `search_history` / `replay_steps`) and can query the session recording through `video_analyzer`. Transient UI (auto-fading control bars, toasts) is handled by chaining taps into one `click_sequence`. *Limitations*: No task plan or notes, no pre-execution safety net, no checkpoint verification or final report, and no ADB shell.
|
* **Flash Profile (`--profile flash`)**: Fast and token-efficient reactive loop (~3–5s per step): one model observes the live screen, thinks, and acts, with no graph orchestration. Ideal for routine, deterministic UI tasks. The loop is unbounded by default (`agent.flash.max_turns`, 0 = unlimited) because history is compressed rather than capped: Flash shares the Pro session transcript ledger (session-relative `T+mm:ss` clock, screenshots folded into visual summaries, older steps chunked into eras and recallable on demand via `search_history` / `replay_steps`) and can query the session recording through `video_analyzer`. Transient UI (auto-fading control bars, toasts) is handled by chaining taps into one `click_sequence`. *Limitations*: No task plan or notes, no pre-execution safety net, no checkpoint verification or final report, and no ADB shell.
|
||||||
* **Pro Profile (`--profile pro`)**: Our most capable architecture (~15–40s per step), built as a multi-agent graph. A **Planner** maintains a living Markdown task plan with milestones and `verify` / `assert` check items; the **Operator** executes it with the full toolset (Explorer grounding whose `flash` / `pro` / `ultra` tier is a user setting per profile — `pro.explorer.mode` / `flash.explorer_mode` in `config/artemis.jsonc` or `--explorer-pro-mode` — never chosen by the agent; notes, history recall, video analysis, ADB diagnostics). Every single action passes a pre-execution **Safety Net** (XML-first, pixel fallback), while multi-action **fast-action bursts** vet only their first member and fire the rest back to back to beat turn latency on transient UI. A blocked or failed action opens an **execution incident** that stays in the Operator's context until a later action succeeds, so recovery is handled by the Operator itself with no separate repair agent. A read-only **Checker** verifies plan checkpoints and runs an exit final review against the original goal (`--verification-level`: `off` / `final` (default) / `checkpoints` / `strict`), and plan milestone edits get an advisory review. Handles 100+ step long-horizon workflows, `[Loop:continuous]` monitoring, and an optional written report.
|
* **Pro Profile (`--profile pro`)**: Our most capable architecture (~15–40s per step), built as a multi-agent graph. A **Planner** maintains a living Markdown task plan with milestones and `verify` / `assert` check items; the **Operator** executes it with the full toolset (Explorer grounding whose `flash` / `pro` / `ultra` tier is a user setting per profile — `pro.explorer.mode` / `flash.explorer_mode` in `config/artemis.jsonc` or `--explorer-pro-mode` — never chosen by the agent; notes, history recall, video analysis, ADB diagnostics). Every single action passes a pre-execution **Safety Net** (XML-first, pixel fallback), while multi-action **fast-action bursts** fire back to back to beat turn latency on transient UI. A blocked or failed action opens an **execution incident** that stays in the Operator's context until a later action succeeds, so recovery is handled by the Operator itself with no separate repair agent. A read-only **Checker** verifies plan checkpoints and runs an exit final review against the original goal (`--verification-level`: `off` / `final` (default) / `checkpoints` / `strict`), and plan milestone edits get an advisory review. Handles 100+ step long-horizon workflows, `[Loop:continuous]` monitoring, and an optional written report.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -276,7 +276,7 @@ if __name__ == "__main__":
|
|||||||
ARTEMIS 提供两种运行模式以适应不同的自动化需求:
|
ARTEMIS 提供两种运行模式以适应不同的自动化需求:
|
||||||
|
|
||||||
* **Flash 模式 (`--profile flash`)**:轻量极速且省 Token 的响应式循环(单步约 3–5 秒):单个模型观察实时屏幕、思考、执行,不经过图编排,适合常规确定性 UI 操作。默认不限步数(`agent.flash.max_turns`,0 表示不限),因为历史是被压缩而不是被截断:Flash 与 Pro 共用同一套会话记录账本(相对测试时间 `T+mm:ss`、截图折叠为视觉摘要、更早的步骤分块归档并可通过 `search_history` / `replay_steps` 按需召回),并可调用 `video_analyzer` 分析整段会话录屏;对自动消失的控制栏、toast 等瞬态控件,用 `click_sequence` 把多次点击串成一个原子序列。*局限性*:没有任务计划与笔记、没有执行前安全网、没有检查点校验与最终报告、不能执行 ADB 命令。
|
* **Flash 模式 (`--profile flash`)**:轻量极速且省 Token 的响应式循环(单步约 3–5 秒):单个模型观察实时屏幕、思考、执行,不经过图编排,适合常规确定性 UI 操作。默认不限步数(`agent.flash.max_turns`,0 表示不限),因为历史是被压缩而不是被截断:Flash 与 Pro 共用同一套会话记录账本(相对测试时间 `T+mm:ss`、截图折叠为视觉摘要、更早的步骤分块归档并可通过 `search_history` / `replay_steps` 按需召回),并可调用 `video_analyzer` 分析整段会话录屏;对自动消失的控制栏、toast 等瞬态控件,用 `click_sequence` 把多次点击串成一个原子序列。*局限性*:没有任务计划与笔记、没有执行前安全网、没有检查点校验与最终报告、不能执行 ADB 命令。
|
||||||
* **Pro 模式 (`--profile pro`)**:最强大的架构(单步约 15–40 秒),由多智能体图编排:**Planner** 维护一份带里程碑和 `verify` / `assert` 检查项的 Markdown 任务计划;**Operator** 按计划执行,拥有全套工具(Explorer 元素定位,其 `flash` / `pro` / `ultra` 三档感知深度是按运行模式设置的用户配置——`config/artemis.jsonc` 里的 `pro.explorer.mode` / `flash.explorer_mode` 或 `--explorer-pro-mode`——智能体自身不会选择档位;笔记、历史召回、视频分析、ADB 诊断)。单个动作执行前都经过 **Safety Net** 校验(XML 优先、像素兜底),而一轮里的多个动作则作为**快速动作序列 (fast-action burst)** 连发:只校验第一个动作,其余动作不经校验背靠背执行,赶在瞬态控件消失前完成操作。动作被拦截或失败时会打开一条**执行事故 (execution incident)**,持续留在 Operator 的上下文里直到后续动作执行成功,恢复由 Operator 自己完成,不再有独立的修复智能体。只读的 **Checker** 校验计划中的检查点,并在退出前对照原始目标做终审(`--verification-level`:`off` / `final`(默认)/ `checkpoints` / `strict`),计划里程碑的改动会得到建议式复核。支持 100+ 步的长程复杂业务流、`[Loop:continuous]` 持续监控以及可选的书面报告。
|
* **Pro 模式 (`--profile pro`)**:最强大的架构(单步约 15–40 秒),由多智能体图编排:**Planner** 维护一份带里程碑和 `verify` / `assert` 检查项的 Markdown 任务计划;**Operator** 按计划执行,拥有全套工具(Explorer 元素定位,其 `flash` / `pro` / `ultra` 三档感知深度是按运行模式设置的用户配置——`config/artemis.jsonc` 里的 `pro.explorer.mode` / `flash.explorer_mode` 或 `--explorer-pro-mode`——智能体自身不会选择档位;笔记、历史召回、视频分析、ADB 诊断)。单个动作执行前都经过 **Safety Net** 校验(XML 优先、像素兜底),而一轮里的多个动作则作为**快速动作序列 (fast-action burst)** 连发,赶在瞬态控件消失前完成操作。动作被拦截或失败时会打开一条**执行事故 (execution incident)**,持续留在 Operator 的上下文里直到后续动作执行成功,恢复由 Operator 自己完成,不再有独立的修复智能体。只读的 **Checker** 校验计划中的检查点,并在退出前对照原始目标做终审(`--verification-level`:`off` / `final`(默认)/ `checkpoints` / `strict`),计划里程碑的改动会得到建议式复核。支持 100+ 步的长程复杂业务流、`[Loop:continuous]` 持续监控以及可选的书面报告。
|
||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
|
|||||||
@@ -566,7 +566,16 @@ def _persist_stream(
|
|||||||
influences booking or release (the ledger stays the source of truth).
|
influences booking or release (the ledger stays the source of truth).
|
||||||
"""
|
"""
|
||||||
segments = capture.segments
|
segments = capture.segments
|
||||||
if not attempt_id or not segments:
|
if not attempt_id:
|
||||||
|
return
|
||||||
|
thoughts = sum(1 for s in segments if s["role"] == "thought")
|
||||||
|
chars = sum(len(s["text"]) for s in segments)
|
||||||
|
# Log empty turns too, so missing transcripts can be checked in the session log.
|
||||||
|
logger.info(
|
||||||
|
f"Checker transcript for {attempt_id}: {len(segments)} segment(s)"
|
||||||
|
f" ({thoughts} thought, {len(segments) - thoughts} answer), {chars} chars"
|
||||||
|
)
|
||||||
|
if not segments:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from artemis.graph.checkpoints import append_attempt_stream_record
|
from artemis.graph.checkpoints import append_attempt_stream_record
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidirectional; never reverse direction merely to prove completion. If the task declares an earlier exit condition (for example, find one verified match or process N items), that explicit condition may end the traversal without reaching the list boundary.\n 3. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtracking.\n 4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for \"double-checking\", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content.\n- **Dynamic Monitoring & Reactive Loop Protocol (Monitor-Trigger-Action)**: When executing an active milestone matching `[Loop] <Goal> into note <TargetNote> (Exit: <ExitCondition>; Interval: <Interval>)` or `[Loop:continuous] <Goal> into note <TargetNote> (Interval: <Interval>)` (the milestone grammar itself is defined under Task Plan Grammar below), run this 5-step cyclic state machine:\n - **Step 1 (Baseline Sync)**: On the initial cycle, observe all visible entries/metrics in the primary monitoring view and save the full snaLine truncated
|
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidirectional; never reverse direction merely to prove completion. If the task declares an earlier exit condition (for example, find one verified match or process N items), that explicit condition may end the traversal without reaching the list boundary.\n 3. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtracking.\n 4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for \"double-checking\", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content.\n- **Dynamic Monitoring & Reactive Loop Protocol (Monitor-Trigger-Action)**: When executing an active milestone matching `[Loop] <Goal> into note <TargetNote> (Exit: <ExitCondition>; Interval: <Interval>)` or `[Loop:continuous] <Goal> into note <TargetNote> (Interval: <Interval>)` (the milestone grammar itself is defined under Task Plan Grammar below), run this 5-step cyclic state machine:\n - **Step 1 (Baseline Sync)**: On the initial cycle, observe all visible entries/metrics in the primary monitoring view and save the full snaLine truncated
|
||||||
}
|
}
|
||||||
@@ -741,8 +741,8 @@ class OperatorNode:
|
|||||||
)
|
)
|
||||||
action_calls_to_translate = []
|
action_calls_to_translate = []
|
||||||
elif len(action_calls) > burst_cap:
|
elif len(action_calls) > burst_cap:
|
||||||
# A multi-action turn is a fast-action burst: the Validator vets its first
|
# A multi-action turn is a fast-action burst that the Validator
|
||||||
# member, fires the rest unvetted; cap its length before it runs.
|
# fires without the safety net; cap its length before it runs.
|
||||||
validation_errors = True
|
validation_errors = True
|
||||||
for tc in action_calls:
|
for tc in action_calls:
|
||||||
tool_outputs.append(
|
tool_outputs.append(
|
||||||
|
|||||||
@@ -30,6 +30,6 @@ You will receive:
|
|||||||
- **CHRONOLOGICAL STEP TRACE (CURRENT STEP)**: The step-by-step chronological history of the current execution turn, including:
|
- **CHRONOLOGICAL STEP TRACE (CURRENT STEP)**: The step-by-step chronological history of the current execution turn, including:
|
||||||
- `[Operator Monologue] / [Operator Native Thought]`: The operator's planning, intentions, progress counting, reasoning, verified states, and explicit mentions of missing prerequisites.
|
- `[Operator Monologue] / [Operator Native Thought]`: The operator's planning, intentions, progress counting, reasoning, verified states, and explicit mentions of missing prerequisites.
|
||||||
- `[Operator Tool Call] / [Operator Final Action]`: The actual action(s) planned and executed.
|
- `[Operator Tool Call] / [Operator Final Action]`: The actual action(s) planned and executed.
|
||||||
- `[Planned Fast-Action Burst]`: A multi-action turn: the first member is vetted by the safety net, the rest fire back to back unvetted; each member's outcome is listed.
|
- `[Planned Fast-Action Burst]`: A multi-action turn executed back to back without the safety net, with each member's outcome.
|
||||||
- `[Pre-Execution Safety Net]`: Validation checks prior to action execution.
|
- `[Pre-Execution Safety Net]`: Validation checks prior to action execution.
|
||||||
- `[Result]`: The Validator's execution outcome; an "Error:" line here is an execution incident the Operator must resolve.
|
- `[Result]`: The Validator's execution outcome; an "Error:" line here is an execution incident the Operator must resolve.
|
||||||
@@ -18,14 +18,12 @@ Two execution tiers, selected by how many turn-ending actions the Operator
|
|||||||
emitted in the turn:
|
emitted in the turn:
|
||||||
|
|
||||||
* **Vetted single action** (one action): the pre-execution Safety Net runs
|
* **Vetted single action** (one action): the pre-execution Safety Net runs
|
||||||
first (XML-first; the pixel judge decides only when the XML verdict is
|
first (XML-first with pixel fallback), then the action executes locally with
|
||||||
weak), then the action executes locally with a short retry loop.
|
a short retry loop.
|
||||||
* **Fast-action burst** (two or more actions): the first member is vetted by
|
* **Fast-action burst** (two or more actions): the actions fire back to back
|
||||||
the same Safety Net (the screen it was decided on is still the live screen)
|
with no safety net, no retries and no screenshots in between. The burst is
|
||||||
and a refusal aborts the whole burst; the remaining members fire back to
|
the Operator's tool against turn latency (transient menus, toasts, control
|
||||||
back with no safety net, no retries and no screenshots in between. The burst
|
bars); the Operator owns the risk. The first failure aborts the rest.
|
||||||
is the Operator's tool against turn latency (transient menus, toasts,
|
|
||||||
control bars); the Operator owns the risk. The first failure aborts the rest.
|
|
||||||
|
|
||||||
Either way, a failure does not spawn a repair agent. The loop opens an
|
Either way, a failure does not spawn a repair agent. The loop opens an
|
||||||
*execution incident* (:mod:`artemis.agents.validator.incidents`) that rides in
|
*execution incident* (:mod:`artemis.agents.validator.incidents`) that rides in
|
||||||
@@ -51,7 +49,6 @@ from artemis.agents.validator.incidents import (
|
|||||||
KIND_SAFETY_NET,
|
KIND_SAFETY_NET,
|
||||||
open_incident,
|
open_incident,
|
||||||
)
|
)
|
||||||
from artemis.agents.validator.precondition_xml import XML_VERDICT_STRONG
|
|
||||||
from artemis.data_engine.trace import CURRENT_TRACE_ID
|
from artemis.data_engine.trace import CURRENT_TRACE_ID
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.utils.coordinates import normalize_action_dict
|
from artemis.utils.coordinates import normalize_action_dict
|
||||||
@@ -76,29 +73,10 @@ class _ActionOutcome:
|
|||||||
post_screenshot_b64: str | None = None
|
post_screenshot_b64: str | None = None
|
||||||
|
|
||||||
|
|
||||||
#: ``safety_net_evidence`` keys the incident carries through verbatim (how the
|
|
||||||
#: verdict was reached), next to the normalized location/occupant facts.
|
|
||||||
_VERDICT_EVIDENCE_KEYS = ("xml_verdict", "xml_element_count", "xml_weak_reason", "pixel_judge")
|
|
||||||
|
|
||||||
|
|
||||||
def _note_pixel_judge(action_item: dict, note: str) -> None:
|
|
||||||
"""Records on the action item how the pixel judge figured in the verdict."""
|
|
||||||
action_item.setdefault("safety_net_evidence", {})["pixel_judge"] = note
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_precondition_gate(
|
async def _run_precondition_gate(
|
||||||
node, session, action_item: dict, state: State, pre_screenshot_b64: str, original_coords: list
|
node, session, action_item: dict, state: State, pre_screenshot_b64: str, original_coords: list
|
||||||
) -> tuple[bool, ValidationErrorCategory, str]:
|
) -> tuple[bool, ValidationErrorCategory, str]:
|
||||||
"""Runs the pre-execution Safety Net: XML-first, pixel judge for weak verdicts.
|
"""Runs the pre-execution Safety Net: XML-first with pixel fallback."""
|
||||||
|
|
||||||
The pixel judge (a VLM call) is a fallback for when the hierarchy could not
|
|
||||||
see the target, never a second opinion when it could. A STRONG XML failure
|
|
||||||
(healthy, non-trivial tree; the target's identifiers were searched and not
|
|
||||||
found) is final and blocks the action without consulting the judge. A WEAK
|
|
||||||
one (XML bypassed/timed out, sparse tree, identifier-less target) hands the
|
|
||||||
decision to the judge. The path taken is recorded in ``safety_net_evidence``
|
|
||||||
and appended to the reason as one short clause.
|
|
||||||
"""
|
|
||||||
is_explorer_candidate = action_item.get("target_class") == "ExplorerCandidate"
|
is_explorer_candidate = action_item.get("target_class") == "ExplorerCandidate"
|
||||||
is_ocr_element = "[OCR]" in str(action_item.get("target_class") or "")
|
is_ocr_element = "[OCR]" in str(action_item.get("target_class") or "")
|
||||||
has_index_metadata = bool(
|
has_index_metadata = bool(
|
||||||
@@ -111,17 +89,14 @@ async def _run_precondition_gate(
|
|||||||
and not is_ocr_element
|
and not is_ocr_element
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _pixel_judge(note: str) -> tuple[bool, ValidationErrorCategory, str]:
|
async def _pixel_check() -> tuple[bool, ValidationErrorCategory, str]:
|
||||||
passed, category, error = await node._validate_action_precondition_pixel(
|
return await node._validate_action_precondition_pixel(
|
||||||
session, action_item, pre_screenshot_b64, original_coords, state=state
|
session, action_item, pre_screenshot_b64, original_coords, state=state
|
||||||
)
|
)
|
||||||
if not passed:
|
|
||||||
_note_pixel_judge(action_item, note)
|
|
||||||
return passed, category, error
|
|
||||||
|
|
||||||
if not has_index_metadata:
|
if not has_index_metadata:
|
||||||
# Pure coordinate/non-index interactions: the pixel judge is the only gate.
|
# Pure coordinate/non-index interactions: pixel-based validation is the only gate.
|
||||||
return await _pixel_judge("consulted alone (target had no hierarchy metadata)")
|
return await _pixel_check()
|
||||||
|
|
||||||
# Index-based validation is fastest and safest when metadata is present
|
# Index-based validation is fastest and safest when metadata is present
|
||||||
(
|
(
|
||||||
@@ -135,37 +110,21 @@ async def _run_precondition_gate(
|
|||||||
|
|
||||||
if validation_category == ValidationErrorCategory.XML_BYPASSED:
|
if validation_category == ValidationErrorCategory.XML_BYPASSED:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Pre-execution XML-based validation bypassed"
|
f"Pre-execution XML-based validation bypassed ({validation_error})."
|
||||||
f" ({validation_error}). Falling back to"
|
" Falling back to Pixel-based validation..."
|
||||||
" Pixel-based validation..."
|
|
||||||
)
|
)
|
||||||
return await _pixel_judge("consulted alone (XML bypassed)")
|
return await _pixel_check()
|
||||||
|
|
||||||
evidence = action_item.get("safety_net_evidence") or {}
|
|
||||||
if evidence.get("xml_verdict") == XML_VERDICT_STRONG:
|
|
||||||
clause = (
|
|
||||||
f"XML verdict final: {evidence.get('xml_element_count')} nodes searched for the"
|
|
||||||
" target's identifiers; pixel judge not consulted"
|
|
||||||
)
|
|
||||||
_note_pixel_judge(action_item, "not consulted (strong XML verdict)")
|
|
||||||
logger.info(f"Pre-execution XML-based validation failed: {validation_error}. {clause}.")
|
|
||||||
return False, validation_category, f"{validation_error} [{clause}]"
|
|
||||||
|
|
||||||
weak_reason = evidence.get("xml_weak_reason") or "no verdict strength recorded"
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Pre-execution XML-based validation failed: {validation_error}. XML evidence is"
|
f"Pre-execution XML-based validation failed: {validation_error}."
|
||||||
f" weak ({weak_reason}); asking the pixel judge..."
|
" Attempting Pixel-based validation fallback..."
|
||||||
)
|
|
||||||
pixel_passed, pixel_category, _pixel_error = await _pixel_judge(
|
|
||||||
"consulted (weak XML verdict); did not confirm the target"
|
|
||||||
)
|
)
|
||||||
|
pixel_passed, pixel_category, _pixel_error = await _pixel_check()
|
||||||
if pixel_passed and pixel_category == ValidationErrorCategory.NONE:
|
if pixel_passed and pixel_category == ValidationErrorCategory.NONE:
|
||||||
logger.success("Pixel judge confirmed the target is present; overriding weak XML verdict.")
|
logger.success("Pixel validation confirmed the target after XML validation failed.")
|
||||||
action_item.pop("safety_net_evidence", None)
|
|
||||||
return True, ValidationErrorCategory.NONE, ""
|
return True, ValidationErrorCategory.NONE, ""
|
||||||
|
|
||||||
clause = f"XML evidence weak ({weak_reason}); pixel judge did not confirm the target"
|
return validation_passed, validation_category, validation_error
|
||||||
return validation_passed, validation_category, f"{validation_error} [{clause}]"
|
|
||||||
|
|
||||||
|
|
||||||
async def _attempt_local_execution(
|
async def _attempt_local_execution(
|
||||||
@@ -215,19 +174,14 @@ async def _process_action(
|
|||||||
pre_screenshot_b64: str,
|
pre_screenshot_b64: str,
|
||||||
*,
|
*,
|
||||||
burst: bool,
|
burst: bool,
|
||||||
vetted: bool = True,
|
|
||||||
) -> _ActionOutcome:
|
) -> _ActionOutcome:
|
||||||
"""Runs precondition checks (``vetted`` items only) and execution for one action item.
|
"""Runs precondition checks (vetted tier only) and execution for one action item."""
|
||||||
|
|
||||||
``vetted`` is True for a single action and for the first member of a burst;
|
|
||||||
later burst members fire unvetted. ``burst`` members never retry.
|
|
||||||
"""
|
|
||||||
action_item = dict(action_item) # Make a copy to avoid mutating original state actions
|
action_item = dict(action_item) # Make a copy to avoid mutating original state actions
|
||||||
outcome = _ActionOutcome(action_item=action_item)
|
outcome = _ActionOutcome(action_item=action_item)
|
||||||
attempts_log = []
|
attempts_log = []
|
||||||
|
|
||||||
validation_passed = True
|
validation_passed = True
|
||||||
if vetted:
|
if not burst:
|
||||||
# 1. Pre-execution validation (Safety Net)
|
# 1. Pre-execution validation (Safety Net)
|
||||||
original_coords = list(action_item.get("coordinates") or [])
|
original_coords = list(action_item.get("coordinates") or [])
|
||||||
(
|
(
|
||||||
@@ -364,9 +318,6 @@ def _incident_evidence(ctx, outcome: _ActionOutcome) -> dict:
|
|||||||
evidence["occupant"] = raw["occupant"]
|
evidence["occupant"] = raw["occupant"]
|
||||||
if raw.get("occupant_bounds") is not None:
|
if raw.get("occupant_bounds") is not None:
|
||||||
evidence["occupant_bounds"] = raw["occupant_bounds"]
|
evidence["occupant_bounds"] = raw["occupant_bounds"]
|
||||||
for key in _VERDICT_EVIDENCE_KEYS:
|
|
||||||
if raw.get(key) is not None:
|
|
||||||
evidence[key] = raw[key]
|
|
||||||
return evidence
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
@@ -425,8 +376,8 @@ async def run_validation_loop(node, state: State) -> dict:
|
|||||||
burst = total_actions > 1
|
burst = total_actions > 1
|
||||||
if burst:
|
if burst:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Fast-action burst: {total_actions} actions; the first is vetted by the"
|
f"Fast-action burst: {total_actions} actions will fire back to back"
|
||||||
" safety net, the rest fire back to back unvetted."
|
" without the safety net."
|
||||||
)
|
)
|
||||||
|
|
||||||
session = await node._get_mcp_session()
|
session = await node._get_mcp_session()
|
||||||
@@ -461,16 +412,7 @@ async def run_validation_loop(node, state: State) -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
outcome = await _process_action(
|
outcome = await _process_action(
|
||||||
node,
|
node, state, session, action_item, action_name, last_screenshot_b64, burst=burst
|
||||||
state,
|
|
||||||
session,
|
|
||||||
action_item,
|
|
||||||
action_name,
|
|
||||||
last_screenshot_b64,
|
|
||||||
burst=burst,
|
|
||||||
# The first burst member was decided on the live screen, so the
|
|
||||||
# safety net still applies to it; later members fire unvetted.
|
|
||||||
vetted=(not burst or index == 0),
|
|
||||||
)
|
)
|
||||||
success = outcome.success
|
success = outcome.success
|
||||||
execution.append(outcome.action_item)
|
execution.append(outcome.action_item)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ UI State Verification Expert. The Operator decided to act at the red dot while l
|
|||||||
Read the `[Target]` block first to determine which rule applies.
|
Read the `[Target]` block first to determine which rule applies.
|
||||||
|
|
||||||
Rules
|
Rules
|
||||||
1. `Kind: specific UI control` — the target has a label, resource id, class or bounds. Judge whether that exact control remains visible at the red dot in Image 2. Ignore video, map, animation or background changes beneath it. A control that has hidden, faded, collapsed, moved or become covered is not present, even if tapping the same spot could restore it. Judge the red dot, not the screen: if a different control, or nothing, now sits under the dot, the target is not present even when the same control (or its label) survives elsewhere on the screen or has turned into a different state such as an active input field.
|
1. `Kind: specific UI control` — the target has a label, resource id, class or bounds. Judge whether that exact control remains visible at the red dot in Image 2. Ignore video, map, animation or background changes beneath it. A control that has hidden, faded, collapsed, moved or become covered is not present, even if tapping the same spot could restore it.
|
||||||
2. `Kind: coordinates only` — no UI hierarchy element was found at the point. Identify what appears under the red dot in both images. If Image 1 shows a distinct button, icon, link or handle, apply Rule 1. Apply the surface rule only when Image 1 shows blank space, a backdrop, or the body of a video, animation, map or live stream.
|
2. `Kind: coordinates only` — no UI hierarchy element was found at the point. Identify what appears under the red dot in both images. If Image 1 shows a distinct button, icon, link or handle, apply Rule 1. Apply the surface rule only when Image 1 shows blank space, a backdrop, or the body of a video, animation, map or live stream.
|
||||||
3. Surface rule. Typical intents: tap the video body to wake its hidden controls, tap the backdrop to dismiss a dialog. Surfaces are expected to change between frames: ignore natural frame changes and the appearance or fading of overlay controls. Output `{"is_present": true, "confidence": 1.0}` unless an unexpected blocking popup now covers the red dot.
|
3. Surface rule. Typical intents: tap the video body to wake its hidden controls, tap the backdrop to dismiss a dialog. Surfaces are expected to change between frames: ignore natural frame changes and the appearance or fading of overlay controls. Output `{"is_present": true, "confidence": 1.0}` unless an unexpected blocking popup now covers the red dot.
|
||||||
4. Use `[Planned Action & Original Thinking]` to resolve whether the target was a control or its underlying surface. Do not treat a missing control as present because another tap could restore it.
|
4. Use `[Planned Action & Original Thinking]` to resolve whether the target was a control or its underlying surface. Do not treat a missing control as present because another tap could restore it.
|
||||||
|
|||||||
@@ -51,15 +51,6 @@ W_TEXT = 0.4
|
|||||||
W_BOUNDS = 0.3
|
W_BOUNDS = 0.3
|
||||||
W_COORD = 0.3
|
W_COORD = 0.3
|
||||||
|
|
||||||
#: Strength of a failed XML verdict, recorded in ``safety_net_evidence``.
|
|
||||||
#: STRONG verdicts are final; WEAK ones hand the decision to the pixel judge.
|
|
||||||
XML_VERDICT_STRONG = "strong"
|
|
||||||
XML_VERDICT_WEAK = "weak"
|
|
||||||
|
|
||||||
#: A live hierarchy with fewer nodes than this is treated as sparse (a WebView,
|
|
||||||
#: game canvas or half-drawn screen): its "not found" is weak evidence.
|
|
||||||
XML_STRONG_MIN_ELEMENTS = 8
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_action_precondition(
|
async def validate_action_precondition(
|
||||||
node, session, action_item: dict, state: State | None = None
|
node, session, action_item: dict, state: State | None = None
|
||||||
@@ -536,45 +527,6 @@ def _describe_occupation(
|
|||||||
return is_occupied, occupant_desc, occupant_bounds
|
return is_occupied, occupant_desc, occupant_bounds
|
||||||
|
|
||||||
|
|
||||||
def _count_nodes(elements) -> int:
|
|
||||||
"""Counts hierarchy nodes, descending into nested ``children`` lists."""
|
|
||||||
total = 0
|
|
||||||
for elem in elements or []:
|
|
||||||
if isinstance(elem, dict):
|
|
||||||
total += 1 + _count_nodes(elem.get("children") or [])
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def classify_xml_verdict(element_count: int, target_text, target_resource_id) -> tuple[str, str]:
|
|
||||||
"""Grades a failed XML verdict as strong or weak.
|
|
||||||
|
|
||||||
STRONG: the hierarchy is non-trivial AND the target carried identifiers
|
|
||||||
(resource-id / text / content-desc) that were searched and not matched.
|
|
||||||
Anything else (sparse tree, identifier-less target such as bare bounds from
|
|
||||||
a hit test) is WEAK, and the pixel judge gets the final say.
|
|
||||||
|
|
||||||
Returns ``(strength, weak_reason)``; ``weak_reason`` is empty for STRONG.
|
|
||||||
"""
|
|
||||||
if element_count < XML_STRONG_MIN_ELEMENTS:
|
|
||||||
return XML_VERDICT_WEAK, f"sparse hierarchy ({element_count} nodes)"
|
|
||||||
if not (target_text or target_resource_id):
|
|
||||||
return XML_VERDICT_WEAK, "target had no resource-id/text to search for"
|
|
||||||
return XML_VERDICT_STRONG, ""
|
|
||||||
|
|
||||||
|
|
||||||
def _annotate_verdict_strength(
|
|
||||||
action_item: dict, elements: list, target_text, target_resource_id
|
|
||||||
) -> None:
|
|
||||||
"""Records the verdict strength on the action item's ``safety_net_evidence``."""
|
|
||||||
count = _count_nodes(elements)
|
|
||||||
strength, weak_reason = classify_xml_verdict(count, target_text, target_resource_id)
|
|
||||||
evidence = action_item.setdefault("safety_net_evidence", {})
|
|
||||||
evidence["xml_verdict"] = strength
|
|
||||||
evidence["xml_element_count"] = count
|
|
||||||
if weak_reason:
|
|
||||||
evidence["xml_weak_reason"] = weak_reason
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_failure(
|
def _classify_failure(
|
||||||
elements: list,
|
elements: list,
|
||||||
action_item: dict,
|
action_item: dict,
|
||||||
@@ -650,9 +602,6 @@ async def validate_action_precondition_single(
|
|||||||
target_bounds = action_item.get("target_bounds") # [l, t, r, b]
|
target_bounds = action_item.get("target_bounds") # [l, t, r, b]
|
||||||
target_resource_id = action_item.get("target_resource_id")
|
target_resource_id = action_item.get("target_resource_id")
|
||||||
|
|
||||||
# Evidence belongs to one attempt; a retry must not inherit a stale verdict.
|
|
||||||
action_item.pop("safety_net_evidence", None)
|
|
||||||
|
|
||||||
width, height = _resolve_screen_dims(ctx, state)
|
width, height = _resolve_screen_dims(ctx, state)
|
||||||
|
|
||||||
# Calculate diagonal-based scale factor for resolution-independent thresholds
|
# Calculate diagonal-based scale factor for resolution-independent thresholds
|
||||||
@@ -710,7 +659,7 @@ async def validate_action_precondition_single(
|
|||||||
if best["score"] >= threshold:
|
if best["score"] >= threshold:
|
||||||
return _handle_match_success(action_item, best, target_text, scale_factor)
|
return _handle_match_success(action_item, best, target_text, scale_factor)
|
||||||
|
|
||||||
verdict = _classify_failure(
|
return _classify_failure(
|
||||||
elements,
|
elements,
|
||||||
action_item,
|
action_item,
|
||||||
best,
|
best,
|
||||||
@@ -721,5 +670,3 @@ async def validate_action_precondition_single(
|
|||||||
height=height,
|
height=height,
|
||||||
scale_factor=scale_factor,
|
scale_factor=scale_factor,
|
||||||
)
|
)
|
||||||
_annotate_verdict_strength(action_item, elements, target_text, target_resource_id)
|
|
||||||
return verdict
|
|
||||||
@@ -705,8 +705,7 @@ class ExecutionConfig(BaseModel):
|
|||||||
le=10,
|
le=10,
|
||||||
description=(
|
description=(
|
||||||
"Maximum turn-ending actions the Operator may chain into one fast-action"
|
"Maximum turn-ending actions the Operator may chain into one fast-action"
|
||||||
" burst (first member vetted by the safety net, the rest fire back to back"
|
" burst (executed back to back without the safety net). A longer turn is"
|
||||||
" unvetted). A longer turn is"
|
|
||||||
" rejected before execution and fed back to the Operator."
|
" rejected before execution and fed back to the Operator."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+64
-24
@@ -46,6 +46,8 @@ SCRCPY_STARTUP_TIMEOUT_SECONDS = 6.0
|
|||||||
VIDEO_READY_DELAY_SECONDS = 1
|
VIDEO_READY_DELAY_SECONDS = 1
|
||||||
ANDROID_DEVICE_VIDEO_PATH = "/sdcard/screen_recording.mp4"
|
ANDROID_DEVICE_VIDEO_PATH = "/sdcard/screen_recording.mp4"
|
||||||
ANDROID_MAX_RECORDING_DURATION_SECONDS = 180 # Android screenrecord limit
|
ANDROID_MAX_RECORDING_DURATION_SECONDS = 180 # Android screenrecord limit
|
||||||
|
# Ignore small timing differences at segment boundaries.
|
||||||
|
TIMELINE_GAP_EPSILON_SECONDS = 0.05
|
||||||
|
|
||||||
# Expanded for Gemini File API (Supports up to 2GB).
|
# Expanded for Gemini File API (Supports up to 2GB).
|
||||||
# Target 100MB to allow 3-5min crisp video and prevent blurring for long durations.
|
# Target 100MB to allow 3-5min crisp video and prevent blurring for long durations.
|
||||||
@@ -529,6 +531,37 @@ async def write_recording_manifest(
|
|||||||
return manifest_path
|
return manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
def plan_timeline_pieces(
|
||||||
|
segments: list[dict[str, Any]], start_time: float, end_time: float
|
||||||
|
) -> list[tuple[Any, ...]]:
|
||||||
|
"""Split a recording window into file ranges and gaps between segments.
|
||||||
|
|
||||||
|
File pieces are ``("file", path, local_start, local_end)``; gaps are
|
||||||
|
``("gap", seconds)``. Include leading and trailing gaps to preserve timeline
|
||||||
|
offsets, ignoring gaps below the timing tolerance. Return an empty list if
|
||||||
|
no existing segment overlaps the window.
|
||||||
|
"""
|
||||||
|
pieces: list[tuple[Any, ...]] = []
|
||||||
|
cursor = start_time
|
||||||
|
for segment in sorted(segments, key=lambda item: float(item["start"])):
|
||||||
|
path = Path(segment["path"])
|
||||||
|
seg_start = float(segment["start"])
|
||||||
|
seg_end = float(segment["end"])
|
||||||
|
overlap_start = max(start_time, seg_start)
|
||||||
|
overlap_end = min(end_time, seg_end)
|
||||||
|
if not path.exists() or overlap_end <= overlap_start:
|
||||||
|
continue
|
||||||
|
if overlap_start - cursor > TIMELINE_GAP_EPSILON_SECONDS:
|
||||||
|
pieces.append(("gap", overlap_start - cursor))
|
||||||
|
pieces.append(("file", path, overlap_start - seg_start, overlap_end - seg_start))
|
||||||
|
cursor = max(cursor, overlap_end)
|
||||||
|
if not any(piece[0] == "file" for piece in pieces):
|
||||||
|
return []
|
||||||
|
if end_time - cursor > TIMELINE_GAP_EPSILON_SECONDS:
|
||||||
|
pieces.append(("gap", end_time - cursor))
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
|
||||||
async def render_timeline_clip(
|
async def render_timeline_clip(
|
||||||
segments: list[dict[str, Any]],
|
segments: list[dict[str, Any]],
|
||||||
start_time: float,
|
start_time: float,
|
||||||
@@ -536,40 +569,47 @@ async def render_timeline_clip(
|
|||||||
output_path: Path,
|
output_path: Path,
|
||||||
canvas_width: int = 720,
|
canvas_width: int = 720,
|
||||||
canvas_height: int = 1280,
|
canvas_height: int = 1280,
|
||||||
|
fps: int = 15,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Render one continuous analyzer clip from orientation-aware segments.
|
"""Render one continuous analyzer clip from orientation-aware segments.
|
||||||
|
|
||||||
Only the requested time window is decoded. This keeps the video analyzer's
|
Decode only the requested window and fill recording gaps with black frames
|
||||||
single-MP4 contract without ever re-encoding the full recording.
|
to keep clip timestamps aligned with the recording timeline.
|
||||||
"""
|
"""
|
||||||
overlaps = []
|
pieces = plan_timeline_pieces(segments, start_time, end_time)
|
||||||
for segment in segments:
|
if not pieces:
|
||||||
path = Path(segment["path"])
|
|
||||||
seg_start = float(segment["start"])
|
|
||||||
seg_end = float(segment["end"])
|
|
||||||
overlap_start = max(start_time, seg_start)
|
|
||||||
overlap_end = min(end_time, seg_end)
|
|
||||||
if path.exists() and overlap_end > overlap_start:
|
|
||||||
overlaps.append((path, overlap_start - seg_start, overlap_end - seg_start))
|
|
||||||
if not overlaps:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command = [get_ffmpeg_path(), "-y", "-fflags", "+genpts"]
|
command = [get_ffmpeg_path(), "-y", "-fflags", "+genpts"]
|
||||||
for path, _start, _end in overlaps:
|
|
||||||
command.extend(["-i", str(path)])
|
|
||||||
|
|
||||||
filter_parts = []
|
filter_parts = []
|
||||||
labels = []
|
labels = []
|
||||||
for index, (_path, local_start, local_end) in enumerate(overlaps):
|
for index, piece in enumerate(pieces):
|
||||||
label = f"v{index}"
|
label = f"v{index}"
|
||||||
filter_parts.append(
|
if piece[0] == "gap":
|
||||||
f"[{index}:v:0]trim=start={local_start:.6f}:end={local_end:.6f},"
|
duration = float(piece[1])
|
||||||
"setpts=PTS-STARTPTS,"
|
command.extend(
|
||||||
f"scale={canvas_width}:{canvas_height}:force_original_aspect_ratio=decrease:"
|
[
|
||||||
"force_divisible_by=2,"
|
"-f",
|
||||||
f"pad={canvas_width}:{canvas_height}:(ow-iw)/2:(oh-ih)/2:color=black,"
|
"lavfi",
|
||||||
f"setsar=1,fps=15,format=yuv420p[{label}]"
|
"-i",
|
||||||
)
|
f"color=c=black:s={canvas_width}x{canvas_height}:r={fps}:d={duration:.3f}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
filter_parts.append(
|
||||||
|
f"[{index}:v:0]trim=duration={duration:.6f},setpts=PTS-STARTPTS,"
|
||||||
|
f"setsar=1,format=yuv420p[{label}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_kind, path, local_start, local_end = piece
|
||||||
|
command.extend(["-i", str(path)])
|
||||||
|
filter_parts.append(
|
||||||
|
f"[{index}:v:0]trim=start={local_start:.6f}:end={local_end:.6f},"
|
||||||
|
"setpts=PTS-STARTPTS,"
|
||||||
|
f"scale={canvas_width}:{canvas_height}:force_original_aspect_ratio=decrease:"
|
||||||
|
"force_divisible_by=2,"
|
||||||
|
f"pad={canvas_width}:{canvas_height}:(ow-iw)/2:(oh-ih)/2:color=black,"
|
||||||
|
f"setsar=1,fps={fps},format=yuv420p[{label}]"
|
||||||
|
)
|
||||||
labels.append(f"[{label}]")
|
labels.append(f"[{label}]")
|
||||||
if len(labels) == 1:
|
if len(labels) == 1:
|
||||||
filter_parts.append(f"{labels[0]}null[outv]")
|
filter_parts.append(f"{labels[0]}null[outv]")
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ from artemis.memory.transcript import (
|
|||||||
|
|
||||||
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
|
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
|
||||||
# Update these when an intentional template change alters the rendered prompt.
|
# Update these when an intentional template change alters the rendered prompt.
|
||||||
GOLDEN_EMPTY_PLAN = "dfcb15a164fa32172cf4bd2ab7b39409229a05dd1d5995eae26992784cfaa4f6"
|
GOLDEN_EMPTY_PLAN = "2ed35be503e3872f008ae5a12616af6f90cde65f23b63adbb6da782079ae4e7c"
|
||||||
GOLDEN_SENTINEL_PLAN = "4b49a3c1ace6387df05a6f1f163dda007e4c2f8ff3d544dac12520317f17d21b"
|
GOLDEN_SENTINEL_PLAN = "8bd6aa60cf3fad8d21cf5c284a01f586c57d209fcd1d2b4a517a791736f9b1de"
|
||||||
|
|
||||||
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
|
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,3 @@ def test_prompt_rules_are_keyed_on_target_kind():
|
|||||||
assert "`Kind: coordinates only`" in prompt
|
assert "`Kind: coordinates only`" in prompt
|
||||||
assert "hidden" in prompt
|
assert "hidden" in prompt
|
||||||
assert "Identify what appears under the red dot" in prompt
|
assert "Identify what appears under the red dot" in prompt
|
||||||
# Rule 1 judges the dot, not the screen: a control surviving elsewhere (or as
|
|
||||||
# an active input field) does not make the tapped spot valid.
|
|
||||||
assert "Judge the red dot, not the screen" in prompt
|
|
||||||
@@ -28,13 +28,6 @@ from langchain_core.messages import AIMessage
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from artemis.agents.validator.categories import ValidationErrorCategory
|
from artemis.agents.validator.categories import ValidationErrorCategory
|
||||||
from artemis.agents.validator.execution_loop import BURST_SKIPPED_MARKER
|
|
||||||
from artemis.agents.validator.precondition_xml import (
|
|
||||||
XML_STRONG_MIN_ELEMENTS,
|
|
||||||
XML_VERDICT_STRONG,
|
|
||||||
XML_VERDICT_WEAK,
|
|
||||||
classify_xml_verdict,
|
|
||||||
)
|
|
||||||
from artemis.agents.validator.validator import ValidatorNode
|
from artemis.agents.validator.validator import ValidatorNode
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||||
@@ -114,28 +107,6 @@ def temp_screenshot(tmp_path):
|
|||||||
return str(p)
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
def _unrelated_hierarchy(count: int) -> list[dict]:
|
|
||||||
"""``count`` labelled rows far below the target region (nothing matches it)."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"text": f"Item {i}",
|
|
||||||
"resource-id": f"row_{i}",
|
|
||||||
"bounds": f"[0,{1000 + i * 100}][1080,{1080 + i * 100}]",
|
|
||||||
"clickable": "true",
|
|
||||||
}
|
|
||||||
for i in range(count)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_LOGIN_TAP = {
|
|
||||||
"action": "tap",
|
|
||||||
"coordinates": [200, 250],
|
|
||||||
"target_text": "Login",
|
|
||||||
"target_bounds": [100, 200, 300, 300],
|
|
||||||
"target_resource_id": "btn_login",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_mcp():
|
def mock_mcp():
|
||||||
fake = FakeActionSession()
|
fake = FakeActionSession()
|
||||||
@@ -212,12 +183,11 @@ async def test_validator_exec_error_opens_incident(mock_mcp, mock_context, temp_
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validator_burst_vets_first_member_only_and_aborts_on_first_failure(
|
async def test_validator_burst_skips_safety_net_and_aborts_on_first_failure(
|
||||||
mock_mcp, mock_context, temp_screenshot
|
mock_mcp, mock_context, temp_screenshot
|
||||||
):
|
):
|
||||||
"""Two or more actions form a fast-action burst: the precondition gate runs
|
"""Two or more actions form a fast-action burst: no precondition gate, a
|
||||||
for the first member only, every member is dispatched once, and the first
|
single dispatch per member, and the first failure aborts the rest."""
|
||||||
failure aborts the rest."""
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def handler(name, args):
|
def handler(name, args):
|
||||||
@@ -245,12 +215,9 @@ async def test_validator_burst_vets_first_member_only_and_aborts_on_first_failur
|
|||||||
) as mock_gate,
|
) as mock_gate,
|
||||||
patch("artemis.utils.image_diff.check_ui_change", return_value=False),
|
patch("artemis.utils.image_diff.check_ui_change", return_value=False),
|
||||||
):
|
):
|
||||||
mock_gate.return_value = (True, ValidationErrorCategory.NONE, "")
|
|
||||||
result = await node(state)
|
result = await node(state)
|
||||||
|
|
||||||
# Only the first member (decided on the still-live screen) is vetted.
|
mock_gate.assert_not_called()
|
||||||
mock_gate.assert_called_once()
|
|
||||||
assert mock_gate.call_args.args[2]["coordinates"] == [105, 205]
|
|
||||||
|
|
||||||
report = result["last_execution_result"]
|
report = result["last_execution_result"]
|
||||||
assert report["burst"] is True
|
assert report["burst"] is True
|
||||||
@@ -272,42 +239,6 @@ async def test_validator_burst_vets_first_member_only_and_aborts_on_first_failur
|
|||||||
assert result["open_incident"] == incident
|
assert result["open_incident"] == incident
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validator_burst_first_member_blocked_aborts_whole_burst(
|
|
||||||
mock_mcp, mock_context, temp_screenshot
|
|
||||||
):
|
|
||||||
"""A refused first member opens a safety-net incident and skips the rest
|
|
||||||
of the burst; nothing is dispatched to the device."""
|
|
||||||
mock_mcp.hierarchy = _unrelated_hierarchy(XML_STRONG_MIN_ELEMENTS + 2)
|
|
||||||
decisions = json.dumps([dict(_LOGIN_TAP), {"action": "press_key", "keycode": "ENTER"}])
|
|
||||||
state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot)
|
|
||||||
|
|
||||||
node = ValidatorNode(mock_context)
|
|
||||||
with (
|
|
||||||
patch("artemis.utils.image_diff.check_ui_change", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ValidatorNode, "_validate_action_precondition_pixel", new_callable=AsyncMock
|
|
||||||
) as mock_pixel,
|
|
||||||
):
|
|
||||||
result = await node(state)
|
|
||||||
|
|
||||||
report = result["last_execution_result"]
|
|
||||||
assert report["burst"] is True
|
|
||||||
assert report["status"] == "failed"
|
|
||||||
assert report["execution"][0]["attempts"][0].startswith("Pre-execution validation failed")
|
|
||||||
assert report["execution"][1]["attempts"] == [BURST_SKIPPED_MARKER]
|
|
||||||
assert mock_mcp.calls_for("click") == []
|
|
||||||
assert mock_mcp.calls_for("press_key") == []
|
|
||||||
mock_pixel.assert_not_called()
|
|
||||||
|
|
||||||
incident = report["incident"]
|
|
||||||
assert incident["kind"] == "safety_net"
|
|
||||||
assert incident["category"] == ValidationErrorCategory.TARGET_DISAPPEARED.value
|
|
||||||
assert incident["action_index"] == 0
|
|
||||||
assert incident["burst_size"] == 2
|
|
||||||
assert result["open_incident"] == incident
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validator_burst_success_executes_every_member(
|
async def test_validator_burst_success_executes_every_member(
|
||||||
mock_mcp, mock_context, temp_screenshot
|
mock_mcp, mock_context, temp_screenshot
|
||||||
@@ -643,126 +574,6 @@ async def test_validator_pre_execution_validation_anonymous_occupant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_classify_xml_verdict_grades_strength():
|
|
||||||
strong, why = classify_xml_verdict(XML_STRONG_MIN_ELEMENTS, "Login", None)
|
|
||||||
assert (strong, why) == (XML_VERDICT_STRONG, "")
|
|
||||||
assert classify_xml_verdict(XML_STRONG_MIN_ELEMENTS, None, "btn_login")[0] == XML_VERDICT_STRONG
|
|
||||||
|
|
||||||
weak, why = classify_xml_verdict(XML_STRONG_MIN_ELEMENTS - 1, "Login", "btn_login")
|
|
||||||
assert weak == XML_VERDICT_WEAK and "sparse hierarchy" in why
|
|
||||||
|
|
||||||
weak, why = classify_xml_verdict(XML_STRONG_MIN_ELEMENTS, None, None)
|
|
||||||
assert weak == XML_VERDICT_WEAK and "no resource-id/text" in why
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validator_strong_xml_missing_verdict_is_final_without_pixel_judge(
|
|
||||||
mock_mcp, mock_context, temp_screenshot
|
|
||||||
):
|
|
||||||
"""A strong XML verdict blocks the action without consulting the pixel judge."""
|
|
||||||
mock_mcp.hierarchy = _unrelated_hierarchy(XML_STRONG_MIN_ELEMENTS + 2)
|
|
||||||
state = DummyState(
|
|
||||||
structured_decisions=json.dumps([dict(_LOGIN_TAP)]), latest_screenshot=temp_screenshot
|
|
||||||
)
|
|
||||||
node = ValidatorNode(mock_context)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("artemis.utils.image_diff.check_ui_change", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
ValidatorNode, "_validate_action_precondition_pixel", new_callable=AsyncMock
|
|
||||||
) as mock_pixel,
|
|
||||||
):
|
|
||||||
# A positive pixel verdict must not override strong XML evidence.
|
|
||||||
mock_pixel.return_value = (True, ValidationErrorCategory.NONE, "")
|
|
||||||
result = await node(state)
|
|
||||||
|
|
||||||
mock_pixel.assert_not_called()
|
|
||||||
assert mock_mcp.calls_for("click") == []
|
|
||||||
|
|
||||||
incident = result["last_execution_result"]["incident"]
|
|
||||||
assert incident["kind"] == "safety_net"
|
|
||||||
assert incident["category"] == ValidationErrorCategory.TARGET_DISAPPEARED.value
|
|
||||||
assert "was not found on the screen" in incident["reason"]
|
|
||||||
assert "pixel judge not consulted" in incident["reason"]
|
|
||||||
assert incident["evidence"]["xml_verdict"] == XML_VERDICT_STRONG
|
|
||||||
assert incident["evidence"]["xml_element_count"] == XML_STRONG_MIN_ELEMENTS + 2
|
|
||||||
assert incident["evidence"]["pixel_judge"] == "not consulted (strong XML verdict)"
|
|
||||||
# The clause also rides in the action record the traces keep.
|
|
||||||
assert (
|
|
||||||
"pixel judge not consulted"
|
|
||||||
in result["last_execution_result"]["execution"][0]["attempts"][0]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validator_weak_xml_sparse_tree_lets_pixel_judge_allow(
|
|
||||||
mock_mcp, mock_context, temp_screenshot
|
|
||||||
):
|
|
||||||
"""A sparse hierarchy (WebView/game-like) cannot vouch for a missing target;
|
|
||||||
the pixel judge decides, and a confident "present" lets the action run."""
|
|
||||||
mock_mcp.hierarchy = _unrelated_hierarchy(2)
|
|
||||||
state = DummyState(
|
|
||||||
structured_decisions=json.dumps([dict(_LOGIN_TAP)]), latest_screenshot=temp_screenshot
|
|
||||||
)
|
|
||||||
node = ValidatorNode(mock_context)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("artemis.utils.image_diff.check_ui_change", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
ValidatorNode, "_validate_action_precondition_pixel", new_callable=AsyncMock
|
|
||||||
) as mock_pixel,
|
|
||||||
):
|
|
||||||
mock_pixel.return_value = (True, ValidationErrorCategory.NONE, "")
|
|
||||||
result = await node(state)
|
|
||||||
|
|
||||||
mock_pixel.assert_called_once()
|
|
||||||
report = result["last_execution_result"]
|
|
||||||
assert report["status"] == "success"
|
|
||||||
assert report["incident"] is None
|
|
||||||
assert len(mock_mcp.calls_for("click")) == 1
|
|
||||||
assert "safety_net_evidence" not in report["execution"][0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validator_weak_xml_identifierless_target_pixel_judge_blocks(
|
|
||||||
mock_mcp, mock_context, temp_screenshot
|
|
||||||
):
|
|
||||||
"""Bare bounds from a hit test carry no identifiers to search for, so the
|
|
||||||
XML "missing" is weak; the pixel judge is consulted and its refusal is
|
|
||||||
recorded next to the XML verdict."""
|
|
||||||
mock_mcp.hierarchy = _unrelated_hierarchy(XML_STRONG_MIN_ELEMENTS + 2)
|
|
||||||
item = {
|
|
||||||
"action": "tap",
|
|
||||||
"coordinates": [200, 250],
|
|
||||||
"target_class": "android.widget.ImageView",
|
|
||||||
"target_bounds": [100, 200, 300, 300],
|
|
||||||
"target_label_source": "hit_test",
|
|
||||||
}
|
|
||||||
state = DummyState(structured_decisions=json.dumps([item]), latest_screenshot=temp_screenshot)
|
|
||||||
node = ValidatorNode(mock_context)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("artemis.utils.image_diff.check_ui_change", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
ValidatorNode, "_validate_action_precondition_pixel", new_callable=AsyncMock
|
|
||||||
) as mock_pixel,
|
|
||||||
):
|
|
||||||
mock_pixel.return_value = (
|
|
||||||
False,
|
|
||||||
ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED,
|
|
||||||
"Pixel check failed",
|
|
||||||
)
|
|
||||||
result = await node(state)
|
|
||||||
|
|
||||||
mock_pixel.assert_called_once()
|
|
||||||
incident = result["last_execution_result"]["incident"]
|
|
||||||
assert incident["category"] == ValidationErrorCategory.TARGET_DISAPPEARED.value
|
|
||||||
assert "XML evidence weak (target had no resource-id/text" in incident["reason"]
|
|
||||||
assert "pixel judge did not confirm the target" in incident["reason"]
|
|
||||||
assert incident["evidence"]["xml_verdict"] == XML_VERDICT_WEAK
|
|
||||||
assert incident["evidence"]["pixel_judge"].startswith("consulted (weak XML verdict)")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_screenshot):
|
async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_screenshot):
|
||||||
"""Test that the pixel safety net succeeds when Gemini reports target is present."""
|
"""Test that the pixel safety net succeeds when Gemini reports target is present."""
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from artemis.utils.video import (
|
|||||||
get_ffmpeg_path,
|
get_ffmpeg_path,
|
||||||
get_active_session,
|
get_active_session,
|
||||||
normalize_recording_to_mp4,
|
normalize_recording_to_mp4,
|
||||||
|
plan_timeline_pieces,
|
||||||
render_timeline_clip,
|
render_timeline_clip,
|
||||||
remove_active_session,
|
remove_active_session,
|
||||||
set_active_session,
|
set_active_session,
|
||||||
@@ -145,6 +146,40 @@ async def test_normalize_recording_handles_resolution_change(tmp_path):
|
|||||||
assert dimensions == {(108, 242)}
|
assert dimensions == {(108, 242)}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_solid_clip(ffmpeg: str, color: str, size: str, duration: float, path) -> None:
|
||||||
|
"""Encode a single-colour test clip (``size`` is ``WxH``) at 15 fps."""
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
f"color={color}:size={size}:rate=15:duration={duration}",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_frames(path) -> list:
|
||||||
|
"""Decode every frame of ``path`` with OpenCV, in order."""
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
frames = []
|
||||||
|
while True:
|
||||||
|
ok, frame = capture.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
frames.append(frame)
|
||||||
|
capture.release()
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_path):
|
async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_path):
|
||||||
"""The analyzer must still receive one decodable MP4 across a rotation."""
|
"""The analyzer must still receive one decodable MP4 across a rotation."""
|
||||||
@@ -152,27 +187,8 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
|
|||||||
portrait = tmp_path / "recording.mkv"
|
portrait = tmp_path / "recording.mkv"
|
||||||
landscape = tmp_path / "recording_001.mkv"
|
landscape = tmp_path / "recording_001.mkv"
|
||||||
output = tmp_path / "agent_clip.mp4"
|
output = tmp_path / "agent_clip.mp4"
|
||||||
for color, size, path in (
|
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, portrait)
|
||||||
("red", "108x242", portrait),
|
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, landscape)
|
||||||
("blue", "242x108", landscape),
|
|
||||||
):
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
ffmpeg,
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
f"color={color}:size={size}:rate=15:duration=1",
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
str(path),
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await render_timeline_clip(
|
assert await render_timeline_clip(
|
||||||
[
|
[
|
||||||
@@ -186,18 +202,91 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
|
|||||||
canvas_height=320,
|
canvas_height=320,
|
||||||
)
|
)
|
||||||
|
|
||||||
capture = cv2.VideoCapture(str(output))
|
frames = _read_frames(output)
|
||||||
dimensions = set()
|
assert {(frame.shape[1], frame.shape[0]) for frame in frames} == {(180, 320)}
|
||||||
frames = 0
|
assert len(frames) >= 20
|
||||||
while True:
|
|
||||||
ok, frame = capture.read()
|
|
||||||
if not ok:
|
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
||||||
break
|
"""Holes between segments (and a window starting in one) become black gaps."""
|
||||||
dimensions.add((frame.shape[1], frame.shape[0]))
|
first = tmp_path / "recording.mkv"
|
||||||
frames += 1
|
second = tmp_path / "recording_001.mkv"
|
||||||
capture.release()
|
first.write_bytes(b"x")
|
||||||
assert dimensions == {(180, 320)}
|
second.write_bytes(b"x")
|
||||||
assert frames >= 20
|
segments = [
|
||||||
|
{"path": first, "start": 0.0, "end": 10.0},
|
||||||
|
{"path": second, "start": 11.5, "end": 20.0}, # scrcpy restart took 1.5s
|
||||||
|
]
|
||||||
|
|
||||||
|
pieces = plan_timeline_pieces(segments, 8.0, 14.0)
|
||||||
|
assert pieces == [
|
||||||
|
("file", first, 8.0, 10.0),
|
||||||
|
("gap", pytest.approx(1.5)),
|
||||||
|
("file", second, 0.0, pytest.approx(2.5)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Window opening inside the hole: the clip's t=0 is still the requested start.
|
||||||
|
pieces = plan_timeline_pieces(segments, 10.5, 13.0)
|
||||||
|
assert pieces == [("gap", pytest.approx(1.0)), ("file", second, 0.0, pytest.approx(1.5))]
|
||||||
|
|
||||||
|
# Sub-epsilon seams are rounding noise, not gaps.
|
||||||
|
seam = [
|
||||||
|
{"path": first, "start": 0.0, "end": 10.0},
|
||||||
|
{"path": second, "start": 10.02, "end": 20.0},
|
||||||
|
]
|
||||||
|
assert [p[0] for p in plan_timeline_pieces(seam, 9.0, 11.0)] == ["file", "file"]
|
||||||
|
|
||||||
|
assert plan_timeline_pieces(segments, 10.1, 11.4) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyzer_clip_keeps_timeline_time_across_restart_gap(tmp_path):
|
||||||
|
"""After a rotation the clip must not run ahead of the recording timeline.
|
||||||
|
|
||||||
|
Two 1s segments with a 1.5s restart hole between them; the requested 3s
|
||||||
|
window must render as 3s of video with black frames where the hole was,
|
||||||
|
so ``start_time + frame_offset`` still names the true recording time.
|
||||||
|
"""
|
||||||
|
ffmpeg = get_ffmpeg_path()
|
||||||
|
portrait = tmp_path / "recording.mkv"
|
||||||
|
landscape = tmp_path / "recording_001.mkv"
|
||||||
|
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, portrait)
|
||||||
|
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, landscape)
|
||||||
|
segments = [
|
||||||
|
{"path": portrait, "start": 0.0, "end": 1.0},
|
||||||
|
{"path": landscape, "start": 2.5, "end": 3.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
output = tmp_path / "agent_clip.mp4"
|
||||||
|
assert await render_timeline_clip(
|
||||||
|
segments, 0.25, 3.25, output, canvas_width=180, canvas_height=320
|
||||||
|
)
|
||||||
|
frames = _read_frames(output)
|
||||||
|
# 3.0s at 15 fps; allow one frame of encoder slack on either side.
|
||||||
|
assert 43 <= len(frames) <= 47
|
||||||
|
|
||||||
|
def is_black(frame) -> bool:
|
||||||
|
return frame.max() < 40
|
||||||
|
|
||||||
|
def is_blue(frame) -> bool:
|
||||||
|
b, g, r = frame[160, 90]
|
||||||
|
return b > 150 and r < 80 and g < 80
|
||||||
|
|
||||||
|
# t=0.5s -> red segment, t=1.5s -> restart hole, t=3.0s -> blue segment.
|
||||||
|
assert not is_black(frames[7]) and not is_blue(frames[7])
|
||||||
|
assert is_black(frames[22])
|
||||||
|
assert is_blue(frames[-1])
|
||||||
|
|
||||||
|
# A window opening inside the hole leads with black, then blue at the
|
||||||
|
# expected timeline offset (2.5s - 1.5s = 1.0s into the clip).
|
||||||
|
output2 = tmp_path / "agent_clip2.mp4"
|
||||||
|
assert await render_timeline_clip(
|
||||||
|
segments, 1.5, 3.25, output2, canvas_width=180, canvas_height=320
|
||||||
|
)
|
||||||
|
frames2 = _read_frames(output2)
|
||||||
|
assert 24 <= len(frames2) <= 28
|
||||||
|
assert is_black(frames2[3])
|
||||||
|
assert is_blue(frames2[20])
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in new issue
Block a user