Add Human demo videos (robot → human, fal MiniMax-H3 480P) as a Video generation option
#1
by pepijn223 HF Staff - opened
- README.md +34 -0
- frontend/src/JobsView.tsx +94 -1
- frontend/src/jobs.css +36 -0
- frontend/src/main.tsx +182 -6
- frontend/src/wizard.css +28 -0
- human-video-plan.txt +14 -0
- human-video-prompt.txt +14 -0
- human_video.py +237 -0
- model_catalog.py +44 -0
- provider.py +21 -0
- server.py +87 -18
- studio.py +140 -17
- test_human_video.py +485 -0
- test_studio.py +1 -1
- test_subset.py +1 -1
- worker.py +109 -9
README.md
CHANGED
|
@@ -105,6 +105,40 @@ combined. Camera names load automatically from the dataset's `meta/info.json`
|
|
| 105 |
before estimating, including private datasets after login. Depth and non-video
|
| 106 |
features are excluded. Visual questions have a configurable cadence.
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
## Prompts and examples
|
| 109 |
|
| 110 |
Every annotation card has an **Edit prompt** editor with the actual model
|
|
|
|
| 105 |
before estimating, including private datasets after login. Depth and non-video
|
| 106 |
features are excluded. Visual questions have a configurable cadence.
|
| 107 |
|
| 108 |
+
## Human demo videos
|
| 109 |
+
|
| 110 |
+
A separate **Video generation** category on the Annotations step, next to the text
|
| 111 |
+
annotations. For every subtask (or up to 1–8 per episode), the job turns the robot view into
|
| 112 |
+
a first-person human demonstration, following LeRobot's `--human_video` annotation module:
|
| 113 |
+
|
| 114 |
+
1. the selected vision model reads the robot segment and writes the human task and an
|
| 115 |
+
image-edit prompt;
|
| 116 |
+
2. `Qwen/Qwen-Image-Edit-2509` removes the robot from the segment's first full-resolution
|
| 117 |
+
frame and adds two resting human hands;
|
| 118 |
+
3. the vision model writes a step-by-step video prompt from the edited frame;
|
| 119 |
+
4. `MiniMaxAI/MiniMax-H3` animates it at 480P (5 s).
|
| 120 |
+
|
| 121 |
+
Both media models run on fal through HF Inference Providers with the signed-in user's
|
| 122 |
+
token. Selecting videos also selects Subtasks, since there is one video per subtask.
|
| 123 |
+
|
| 124 |
+
The estimate follows the selected episodes' lengths: it expects about one subtask per 5 s of
|
| 125 |
+
each episode (at least one per episode), allows twice that in the upper bound, and applies the
|
| 126 |
+
per-episode cap. **Parallel generations** (1–32, default 8) limits simultaneous media
|
| 127 |
+
requests across the whole job. The review warns when the expected videos at that parallelism
|
| 128 |
+
would outlast the job timeout (about 3 min per video).
|
| 129 |
+
|
| 130 |
+
Videos start only after an episode's
|
| 131 |
+
annotations pass validation, and each request reserves its quoted price
|
| 132 |
+
(`model_catalog.HUMAN_VIDEO`) from the spending target first. A failed video is recorded
|
| 133 |
+
and the others continue. Reaching the target or the runtime limit stops further videos but
|
| 134 |
+
keeps the episode's annotations. The launch checks that both fal mappings are live.
|
| 135 |
+
|
| 136 |
+
The output holds `annotation_studio/human_videos/episode_XXXXXX/segment_XXX{.mp4,
|
| 137 |
+
_first_frame.png,_robot_frame.png}` (named by source episode) and `index.json`, which lists
|
| 138 |
+
each segment's subtask, timestamps, prompts, status and materialized `episode_index`.
|
| 139 |
+
These are synthetic media, not language columns. The Jobs page previews completed videos
|
| 140 |
+
next to the robot frame, served through the Space so private outputs stay private.
|
| 141 |
+
|
| 142 |
## Prompts and examples
|
| 143 |
|
| 144 |
Every annotation card has an **Edit prompt** editor with the actual model
|
frontend/src/JobsView.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import
|
| 2 |
import {
|
| 3 |
ArrowUpRight,
|
| 4 |
ChevronDown,
|
|
@@ -30,6 +30,8 @@ export type Progress = {
|
|
| 30 |
input_tokens?: number;
|
| 31 |
output_tokens?: number;
|
| 32 |
rate_limited_requests?: number;
|
|
|
|
|
|
|
| 33 |
};
|
| 34 |
cost?: {
|
| 35 |
inference_usd: number | null;
|
|
@@ -42,7 +44,81 @@ export type Progress = {
|
|
| 42 |
};
|
| 43 |
annotation_url?: string | null;
|
| 44 |
output_mode?: string;
|
|
|
|
| 45 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
|
| 47 |
const usd = (value: number | null | undefined) =>
|
| 48 |
value == null
|
|
@@ -252,7 +328,24 @@ export default function JobsView({
|
|
| 252 |
{p?.usage.successful_calls?.toLocaleString() ?? "—"}
|
| 253 |
</dd>
|
| 254 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</dl>
|
|
|
|
|
|
|
|
|
|
| 256 |
{p &&
|
| 257 |
(p.failed > 0 ||
|
| 258 |
(p.stopped || 0) > 0 ||
|
|
|
|
| 1 |
+
import { useState, type ReactNode } from "react";
|
| 2 |
import {
|
| 3 |
ArrowUpRight,
|
| 4 |
ChevronDown,
|
|
|
|
| 30 |
input_tokens?: number;
|
| 31 |
output_tokens?: number;
|
| 32 |
rate_limited_requests?: number;
|
| 33 |
+
media_usd?: number;
|
| 34 |
+
completed_media_requests?: number;
|
| 35 |
};
|
| 36 |
cost?: {
|
| 37 |
inference_usd: number | null;
|
|
|
|
| 44 |
};
|
| 45 |
annotation_url?: string | null;
|
| 46 |
output_mode?: string;
|
| 47 |
+
human_videos?: Record<string, number> | null;
|
| 48 |
};
|
| 49 |
+
type HumanVideo = {
|
| 50 |
+
episode_index?: number | null;
|
| 51 |
+
source_episode_index: number;
|
| 52 |
+
segment_index?: number;
|
| 53 |
+
subtask?: string;
|
| 54 |
+
caption?: string | null;
|
| 55 |
+
status: string;
|
| 56 |
+
robot_frame?: string;
|
| 57 |
+
first_frame?: string;
|
| 58 |
+
video?: string;
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
function HumanVideos({ jobId }: { jobId: string }) {
|
| 62 |
+
const [videos, setVideos] = useState<HumanVideo[] | null>(null);
|
| 63 |
+
const [error, setError] = useState("");
|
| 64 |
+
const [loading, setLoading] = useState(false);
|
| 65 |
+
async function load() {
|
| 66 |
+
setLoading(true);
|
| 67 |
+
setError("");
|
| 68 |
+
try {
|
| 69 |
+
const response = await fetch(`/api/jobs/${jobId}/human-videos`, {
|
| 70 |
+
credentials: "same-origin",
|
| 71 |
+
});
|
| 72 |
+
if (!response.ok) throw new Error("No human demo videos saved yet.");
|
| 73 |
+
setVideos(await response.json());
|
| 74 |
+
} catch (e) {
|
| 75 |
+
setError((e as Error).message);
|
| 76 |
+
} finally {
|
| 77 |
+
setLoading(false);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
if (!videos)
|
| 81 |
+
return (
|
| 82 |
+
<div className="human-videos">
|
| 83 |
+
<button className="text-action" onClick={load} disabled={loading}>
|
| 84 |
+
{loading ? "Loading videos…" : "Preview human demo videos"}
|
| 85 |
+
</button>
|
| 86 |
+
{error && <p className="job-cost-note">{error}</p>}
|
| 87 |
+
</div>
|
| 88 |
+
);
|
| 89 |
+
const ready = videos.filter((v) => v.status === "completed" && v.video);
|
| 90 |
+
return (
|
| 91 |
+
<div className="human-videos">
|
| 92 |
+
{ready.length === 0 && <p>No completed videos yet.</p>}
|
| 93 |
+
<div className="human-video-grid">
|
| 94 |
+
{ready.map((v) => (
|
| 95 |
+
<figure key={`${v.source_episode_index}-${v.segment_index}`}>
|
| 96 |
+
<div className="human-video-pair">
|
| 97 |
+
{v.robot_frame && (
|
| 98 |
+
<img src={v.robot_frame} alt="Robot view" loading="lazy" />
|
| 99 |
+
)}
|
| 100 |
+
<video
|
| 101 |
+
src={v.video}
|
| 102 |
+
poster={v.first_frame}
|
| 103 |
+
controls
|
| 104 |
+
muted
|
| 105 |
+
loop
|
| 106 |
+
playsInline
|
| 107 |
+
preload="none"
|
| 108 |
+
/>
|
| 109 |
+
</div>
|
| 110 |
+
<figcaption>
|
| 111 |
+
<strong>
|
| 112 |
+
Episode {v.episode_index ?? v.source_episode_index}
|
| 113 |
+
</strong>{" "}
|
| 114 |
+
· {v.subtask}
|
| 115 |
+
</figcaption>
|
| 116 |
+
</figure>
|
| 117 |
+
))}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
|
| 123 |
const usd = (value: number | null | undefined) =>
|
| 124 |
value == null
|
|
|
|
| 328 |
{p?.usage.successful_calls?.toLocaleString() ?? "—"}
|
| 329 |
</dd>
|
| 330 |
</div>
|
| 331 |
+
{p?.human_videos && (
|
| 332 |
+
<div>
|
| 333 |
+
<dt>Human videos</dt>
|
| 334 |
+
<dd>
|
| 335 |
+
{(p.human_videos.completed || 0).toLocaleString()}
|
| 336 |
+
{p.human_videos.failed
|
| 337 |
+
? ` · ${p.human_videos.failed} failed`
|
| 338 |
+
: ""}
|
| 339 |
+
{p.usage.media_usd
|
| 340 |
+
? ` · ${usd(p.usage.media_usd)}`
|
| 341 |
+
: ""}
|
| 342 |
+
</dd>
|
| 343 |
+
</div>
|
| 344 |
+
)}
|
| 345 |
</dl>
|
| 346 |
+
{!!p?.human_videos?.completed && (
|
| 347 |
+
<HumanVideos jobId={job.id} />
|
| 348 |
+
)}
|
| 349 |
{p &&
|
| 350 |
(p.failed > 0 ||
|
| 351 |
(p.stopped || 0) > 0 ||
|
frontend/src/jobs.css
CHANGED
|
@@ -312,3 +312,39 @@
|
|
| 312 |
flex-direction: row;
|
| 313 |
align-items: center;
|
| 314 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
flex-direction: row;
|
| 313 |
align-items: center;
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
.human-videos {
|
| 317 |
+
margin-top: 14px;
|
| 318 |
+
font-size: 12px;
|
| 319 |
+
}
|
| 320 |
+
.human-video-grid {
|
| 321 |
+
display: grid;
|
| 322 |
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
| 323 |
+
gap: 14px;
|
| 324 |
+
}
|
| 325 |
+
.human-video-pair {
|
| 326 |
+
display: grid;
|
| 327 |
+
grid-template-columns: 1fr 1fr;
|
| 328 |
+
gap: 6px;
|
| 329 |
+
}
|
| 330 |
+
.human-video-pair img,
|
| 331 |
+
.human-video-pair video {
|
| 332 |
+
width: 100%;
|
| 333 |
+
aspect-ratio: 4 / 3;
|
| 334 |
+
object-fit: cover;
|
| 335 |
+
border-radius: 6px;
|
| 336 |
+
background: var(--line-subtle);
|
| 337 |
+
}
|
| 338 |
+
.human-videos figure {
|
| 339 |
+
margin: 0;
|
| 340 |
+
}
|
| 341 |
+
.human-videos figcaption {
|
| 342 |
+
margin-top: 6px;
|
| 343 |
+
color: var(--fg-muted);
|
| 344 |
+
line-height: 1.5;
|
| 345 |
+
}
|
| 346 |
+
@media (max-width: 480px) {
|
| 347 |
+
.human-video-grid {
|
| 348 |
+
grid-template-columns: 1fr;
|
| 349 |
+
}
|
| 350 |
+
}
|
frontend/src/main.tsx
CHANGED
|
@@ -45,7 +45,9 @@ type Feature =
|
|
| 45 |
| "Memory"
|
| 46 |
| "Task rephrasings"
|
| 47 |
| "Interjections & speech"
|
| 48 |
-
| "Visual questions"
|
|
|
|
|
|
|
| 49 |
type Options = {
|
| 50 |
model: string;
|
| 51 |
dataset: string;
|
|
@@ -57,6 +59,8 @@ type Options = {
|
|
| 57 |
hours: number;
|
| 58 |
mode: string;
|
| 59 |
prompts: Record<string, string>;
|
|
|
|
|
|
|
| 60 |
};
|
| 61 |
type PromptTemplate = {
|
| 62 |
key: string;
|
|
@@ -84,10 +88,26 @@ type ModelProfile = {
|
|
| 84 |
pricing_url: string;
|
| 85 |
pricing_note?: string;
|
| 86 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
type ModelCatalog = {
|
| 88 |
models: ModelProfile[];
|
| 89 |
default: string;
|
| 90 |
unavailable: { name: string; reason: string }[];
|
|
|
|
| 91 |
};
|
| 92 |
type Quote = {
|
| 93 |
model_profile: ModelProfile;
|
|
@@ -96,6 +116,13 @@ type Quote = {
|
|
| 96 |
low: number;
|
| 97 |
high: number;
|
| 98 |
inference: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
cpu_max: number;
|
| 100 |
custom_prompts: number;
|
| 101 |
calls: number;
|
|
@@ -133,6 +160,8 @@ const defaults: Options = {
|
|
| 133 |
hours: 4,
|
| 134 |
mode: COPY,
|
| 135 |
prompts: {},
|
|
|
|
|
|
|
| 136 |
};
|
| 137 |
const definitions: {
|
| 138 |
name: Feature;
|
|
@@ -519,11 +548,16 @@ function App() {
|
|
| 519 |
setNotice("");
|
| 520 |
}
|
| 521 |
function toggle(name: Feature) {
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
}
|
| 528 |
async function estimate(advance = false) {
|
| 529 |
setLoading(true);
|
|
@@ -936,6 +970,12 @@ function App() {
|
|
| 936 |
className="card step-card"
|
| 937 |
aria-label="Annotation settings"
|
| 938 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
<div className="annotation-choices">
|
| 940 |
{definitions.map(({ name, title, description }) => (
|
| 941 |
<div
|
|
@@ -1005,6 +1045,111 @@ function App() {
|
|
| 1005 |
</details>
|
| 1006 |
</section>
|
| 1007 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
{step === 2 && (
|
| 1009 |
<section
|
| 1010 |
className="card step-card model-picker"
|
|
@@ -1263,6 +1408,19 @@ function App() {
|
|
| 1263 |
<span>–</span>
|
| 1264 |
{money(quote.costs.high)}
|
| 1265 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1266 |
<details className="progressive-details cost-breakdown">
|
| 1267 |
<summary>
|
| 1268 |
Cost breakdown <ChevronDown size={15} />
|
|
@@ -1276,6 +1434,24 @@ function App() {
|
|
| 1276 |
: "—"}
|
| 1277 |
</strong>
|
| 1278 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1279 |
<div>
|
| 1280 |
<span>
|
| 1281 |
CPU job reservation{" "}
|
|
|
|
| 45 |
| "Memory"
|
| 46 |
| "Task rephrasings"
|
| 47 |
| "Interjections & speech"
|
| 48 |
+
| "Visual questions"
|
| 49 |
+
| "Human demo videos";
|
| 50 |
+
const HUMAN_VIDEOS: Feature = "Human demo videos";
|
| 51 |
type Options = {
|
| 52 |
model: string;
|
| 53 |
dataset: string;
|
|
|
|
| 59 |
hours: number;
|
| 60 |
mode: string;
|
| 61 |
prompts: Record<string, string>;
|
| 62 |
+
human_video_segments: number;
|
| 63 |
+
human_video_parallel: number;
|
| 64 |
};
|
| 65 |
type PromptTemplate = {
|
| 66 |
key: string;
|
|
|
|
| 88 |
pricing_url: string;
|
| 89 |
pricing_note?: string;
|
| 90 |
};
|
| 91 |
+
type HumanVideoProfile = {
|
| 92 |
+
provider_name: string;
|
| 93 |
+
edit_model: string;
|
| 94 |
+
video_model: string;
|
| 95 |
+
resolution: string;
|
| 96 |
+
duration_s: number;
|
| 97 |
+
edit_usd: number;
|
| 98 |
+
video_usd: number;
|
| 99 |
+
checked_at: string;
|
| 100 |
+
pricing_url: string;
|
| 101 |
+
segment_choices: number[];
|
| 102 |
+
parallel_choices: number[];
|
| 103 |
+
seconds_per_subtask: number;
|
| 104 |
+
minutes_per_video: number;
|
| 105 |
+
};
|
| 106 |
type ModelCatalog = {
|
| 107 |
models: ModelProfile[];
|
| 108 |
default: string;
|
| 109 |
unavailable: { name: string; reason: string }[];
|
| 110 |
+
human_video?: HumanVideoProfile;
|
| 111 |
};
|
| 112 |
type Quote = {
|
| 113 |
model_profile: ModelProfile;
|
|
|
|
| 116 |
low: number;
|
| 117 |
high: number;
|
| 118 |
inference: number;
|
| 119 |
+
media?: number;
|
| 120 |
+
media_max?: number;
|
| 121 |
+
videos?: number;
|
| 122 |
+
videos_max?: number;
|
| 123 |
+
video_minutes?: number;
|
| 124 |
+
video_parallel?: number;
|
| 125 |
+
segment_usd?: number;
|
| 126 |
cpu_max: number;
|
| 127 |
custom_prompts: number;
|
| 128 |
calls: number;
|
|
|
|
| 160 |
hours: 4,
|
| 161 |
mode: COPY,
|
| 162 |
prompts: {},
|
| 163 |
+
human_video_segments: 0,
|
| 164 |
+
human_video_parallel: 8,
|
| 165 |
};
|
| 166 |
const definitions: {
|
| 167 |
name: Feature;
|
|
|
|
| 548 |
setNotice("");
|
| 549 |
}
|
| 550 |
function toggle(name: Feature) {
|
| 551 |
+
const on = !options.features.includes(name);
|
| 552 |
+
let features = on
|
| 553 |
+
? [...options.features, name]
|
| 554 |
+
: options.features.filter((x) => x !== name);
|
| 555 |
+
// Videos are generated per subtask, so they need the subtask annotations.
|
| 556 |
+
if (on && name === HUMAN_VIDEOS && !features.includes("Subtasks"))
|
| 557 |
+
features = ["Subtasks", ...features];
|
| 558 |
+
if (!on && name === "Subtasks")
|
| 559 |
+
features = features.filter((x) => x !== HUMAN_VIDEOS);
|
| 560 |
+
change({ features });
|
| 561 |
}
|
| 562 |
async function estimate(advance = false) {
|
| 563 |
setLoading(true);
|
|
|
|
| 970 |
className="card step-card"
|
| 971 |
aria-label="Annotation settings"
|
| 972 |
>
|
| 973 |
+
<div className="category-heading">
|
| 974 |
+
<h3>Text annotations</h3>
|
| 975 |
+
<p className="field-help">
|
| 976 |
+
Language written into the dataset's Parquet files.
|
| 977 |
+
</p>
|
| 978 |
+
</div>
|
| 979 |
<div className="annotation-choices">
|
| 980 |
{definitions.map(({ name, title, description }) => (
|
| 981 |
<div
|
|
|
|
| 1045 |
</details>
|
| 1046 |
</section>
|
| 1047 |
)}
|
| 1048 |
+
{step === 1 && (
|
| 1049 |
+
<section
|
| 1050 |
+
className={`card step-card video-category ${options.features.includes(HUMAN_VIDEOS) ? "selected" : ""}`}
|
| 1051 |
+
aria-label="Video generation settings"
|
| 1052 |
+
>
|
| 1053 |
+
<div className="category-heading">
|
| 1054 |
+
<h3>
|
| 1055 |
+
<Video size={16} /> Video generation
|
| 1056 |
+
</h3>
|
| 1057 |
+
<p className="field-help">
|
| 1058 |
+
New media generated from your robot videos, saved next to
|
| 1059 |
+
the dataset.
|
| 1060 |
+
</p>
|
| 1061 |
+
</div>
|
| 1062 |
+
<div className="annotation-choice">
|
| 1063 |
+
<label>
|
| 1064 |
+
<input
|
| 1065 |
+
type="checkbox"
|
| 1066 |
+
checked={options.features.includes(HUMAN_VIDEOS)}
|
| 1067 |
+
onChange={() => toggle(HUMAN_VIDEOS)}
|
| 1068 |
+
/>
|
| 1069 |
+
<span>
|
| 1070 |
+
<strong>Human demo videos</strong>
|
| 1071 |
+
<small>
|
| 1072 |
+
A first-person video of human hands doing each
|
| 1073 |
+
subtask: the robot is edited out of the first frame,
|
| 1074 |
+
then the scene is animated.
|
| 1075 |
+
</small>
|
| 1076 |
+
</span>
|
| 1077 |
+
</label>
|
| 1078 |
+
</div>
|
| 1079 |
+
{options.features.includes(HUMAN_VIDEOS) && (
|
| 1080 |
+
<div className="fields-row video-settings">
|
| 1081 |
+
<label>
|
| 1082 |
+
Videos per episode
|
| 1083 |
+
<select
|
| 1084 |
+
value={options.human_video_segments}
|
| 1085 |
+
onChange={(e) =>
|
| 1086 |
+
change({
|
| 1087 |
+
human_video_segments: Number(e.target.value),
|
| 1088 |
+
})
|
| 1089 |
+
}
|
| 1090 |
+
>
|
| 1091 |
+
{(
|
| 1092 |
+
modelCatalog?.human_video?.segment_choices ?? [
|
| 1093 |
+
0, 1, 2, 3, 5, 8,
|
| 1094 |
+
]
|
| 1095 |
+
).map((n) => (
|
| 1096 |
+
<option key={n} value={n}>
|
| 1097 |
+
{n === 0 ? "Every subtask" : `Up to ${n}`}
|
| 1098 |
+
</option>
|
| 1099 |
+
))}
|
| 1100 |
+
</select>
|
| 1101 |
+
</label>
|
| 1102 |
+
<label>
|
| 1103 |
+
Parallel generations
|
| 1104 |
+
<select
|
| 1105 |
+
value={options.human_video_parallel}
|
| 1106 |
+
onChange={(e) =>
|
| 1107 |
+
change({
|
| 1108 |
+
human_video_parallel: Number(e.target.value),
|
| 1109 |
+
})
|
| 1110 |
+
}
|
| 1111 |
+
>
|
| 1112 |
+
{(
|
| 1113 |
+
modelCatalog?.human_video?.parallel_choices ?? [
|
| 1114 |
+
1, 4, 8, 16, 32,
|
| 1115 |
+
]
|
| 1116 |
+
).map((n) => (
|
| 1117 |
+
<option key={n} value={n}>
|
| 1118 |
+
Up to {n} at a time
|
| 1119 |
+
</option>
|
| 1120 |
+
))}
|
| 1121 |
+
</select>
|
| 1122 |
+
</label>
|
| 1123 |
+
</div>
|
| 1124 |
+
)}
|
| 1125 |
+
{modelCatalog?.human_video && (
|
| 1126 |
+
<p className="field-help dependency-hint">
|
| 1127 |
+
{modelCatalog.human_video.edit_model} →{" "}
|
| 1128 |
+
{modelCatalog.human_video.video_model}{" "}
|
| 1129 |
+
{modelCatalog.human_video.resolution} via{" "}
|
| 1130 |
+
{modelCatalog.human_video.provider_name} on Inference
|
| 1131 |
+
Providers · about{" "}
|
| 1132 |
+
{money(
|
| 1133 |
+
modelCatalog.human_video.edit_usd +
|
| 1134 |
+
modelCatalog.human_video.video_usd,
|
| 1135 |
+
)}{" "}
|
| 1136 |
+
per video, billed to your account. The estimate counts
|
| 1137 |
+
about one subtask per{" "}
|
| 1138 |
+
{modelCatalog.human_video.seconds_per_subtask} s of each
|
| 1139 |
+
episode.
|
| 1140 |
+
{options.features.includes(HUMAN_VIDEOS) &&
|
| 1141 |
+
" One video per subtask, so Subtasks are included."}{" "}
|
| 1142 |
+
<a
|
| 1143 |
+
href={modelCatalog.human_video.pricing_url}
|
| 1144 |
+
target="_blank"
|
| 1145 |
+
rel="noreferrer"
|
| 1146 |
+
>
|
| 1147 |
+
Pricing
|
| 1148 |
+
</a>
|
| 1149 |
+
</p>
|
| 1150 |
+
)}
|
| 1151 |
+
</section>
|
| 1152 |
+
)}
|
| 1153 |
{step === 2 && (
|
| 1154 |
<section
|
| 1155 |
className="card step-card model-picker"
|
|
|
|
| 1408 |
<span>–</span>
|
| 1409 |
{money(quote.costs.high)}
|
| 1410 |
</div>
|
| 1411 |
+
{!!quote.costs.video_minutes &&
|
| 1412 |
+
quote.costs.video_minutes >
|
| 1413 |
+
quote.costs.hours * 60 && (
|
| 1414 |
+
<p className="field-help video-time-warning">
|
| 1415 |
+
~{num(quote.costs.videos ?? 0)} videos at{" "}
|
| 1416 |
+
{quote.costs.video_parallel} at a time may need
|
| 1417 |
+
about {Math.ceil(quote.costs.video_minutes / 60)}{" "}
|
| 1418 |
+
h, longer than the {quote.costs.hours} h timeout.
|
| 1419 |
+
Later videos would be skipped; raise parallel
|
| 1420 |
+
generations or the timeout, or cap videos per
|
| 1421 |
+
episode.
|
| 1422 |
+
</p>
|
| 1423 |
+
)}
|
| 1424 |
<details className="progressive-details cost-breakdown">
|
| 1425 |
<summary>
|
| 1426 |
Cost breakdown <ChevronDown size={15} />
|
|
|
|
| 1434 |
: "—"}
|
| 1435 |
</strong>
|
| 1436 |
</div>
|
| 1437 |
+
{!!quote.costs.videos && (
|
| 1438 |
+
<div>
|
| 1439 |
+
<span>
|
| 1440 |
+
Human demo videos (~{num(quote.costs.videos)},
|
| 1441 |
+
up to {num(quote.costs.videos_max ?? 0)}){" "}
|
| 1442 |
+
<span
|
| 1443 |
+
className="info-tooltip"
|
| 1444 |
+
title="One video per subtask, estimated from each episode's length. Each video reserves its quoted price before it starts; the spending target stops further videos."
|
| 1445 |
+
>
|
| 1446 |
+
<HelpCircle size={12} />
|
| 1447 |
+
</span>
|
| 1448 |
+
</span>
|
| 1449 |
+
<strong>
|
| 1450 |
+
{money(quote.costs.media ?? 0)}–
|
| 1451 |
+
{money(quote.costs.media_max ?? 0)}
|
| 1452 |
+
</strong>
|
| 1453 |
+
</div>
|
| 1454 |
+
)}
|
| 1455 |
<div>
|
| 1456 |
<span>
|
| 1457 |
CPU job reservation{" "}
|
frontend/src/wizard.css
CHANGED
|
@@ -201,6 +201,34 @@
|
|
| 201 |
.dependency-hint {
|
| 202 |
margin-top: 20px;
|
| 203 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
.text-action {
|
| 205 |
display: inline-block;
|
| 206 |
color: var(--fg-muted);
|
|
|
|
| 201 |
.dependency-hint {
|
| 202 |
margin-top: 20px;
|
| 203 |
}
|
| 204 |
+
.category-heading {
|
| 205 |
+
margin-bottom: 18px;
|
| 206 |
+
}
|
| 207 |
+
.category-heading h3 {
|
| 208 |
+
display: flex;
|
| 209 |
+
align-items: center;
|
| 210 |
+
gap: 8px;
|
| 211 |
+
font-size: 14px;
|
| 212 |
+
font-weight: 600;
|
| 213 |
+
margin: 0 0 4px;
|
| 214 |
+
}
|
| 215 |
+
.category-heading .field-help {
|
| 216 |
+
margin: 0;
|
| 217 |
+
}
|
| 218 |
+
.video-category {
|
| 219 |
+
margin-top: 16px;
|
| 220 |
+
}
|
| 221 |
+
.video-category .annotation-choice {
|
| 222 |
+
padding: 0;
|
| 223 |
+
border: 0;
|
| 224 |
+
}
|
| 225 |
+
.video-settings {
|
| 226 |
+
margin-top: 16px;
|
| 227 |
+
}
|
| 228 |
+
.video-time-warning {
|
| 229 |
+
color: var(--warning, #b45309);
|
| 230 |
+
margin-top: 8px;
|
| 231 |
+
}
|
| 232 |
.text-action {
|
| 233 |
display: inline-block;
|
| 234 |
color: var(--fg-muted);
|
human-video-plan.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are preparing a human demonstration of one step of a robot manipulation video.
|
| 2 |
+
|
| 3 |
+
The {n} images are frames sampled uniformly from the robot's segment ({duration:.1f} s); image 1 is its first frame.
|
| 4 |
+
Subtask performed in this segment (authoritative): "{subtask}"
|
| 5 |
+
Episode instruction (context only): "{task}"
|
| 6 |
+
|
| 7 |
+
Return one JSON object with exactly these keys:
|
| 8 |
+
- "human_task": the subtask phrased for a person, naming the hand that acts (the hand on the same image side as the robot arm that acts), e.g. "Pick up the red cup with the right hand and place it on the tray."
|
| 9 |
+
- "object": short name and appearance of the manipulated object.
|
| 10 |
+
- "target": short name and appearance of where it goes (or "" if there is none).
|
| 11 |
+
- "hand": "left" or "right".
|
| 12 |
+
- "steps": list of 2 to 4 short steps describing how the objects move during the segment.
|
| 13 |
+
- "final_state": one short phrase describing the final object state.
|
| 14 |
+
- "edit_prompt": an instruction for an image-editing model that turns image 1 into a first-person view of a person doing the task. It must: (1) completely remove every robot arm, gripper, cable and robot base, filling those areas with the continuing table and background; (2) add exactly two realistic human hands and forearms (the viewer's own) entering from the bottom edge and resting relaxed on the near edge of the table, touching nothing; (3) list every object that must stay exactly as it is, with colour and position; (4) keep the camera viewpoint, table, background, lighting and layout unchanged and photorealistic. It must not add any other people, arms or objects.
|
human-video-prompt.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Image 1 is the first frame of a new first-person video of a person doing a task; an image-to-video model will animate it.
|
| 2 |
+
Images 2 onwards show how a robot did the same task; use them only for the order of events and object motion. No robot may appear in the new video.
|
| 3 |
+
|
| 4 |
+
Task: "{human_task}"
|
| 5 |
+
Object: {object}
|
| 6 |
+
Target: {target}
|
| 7 |
+
Steps: {steps}
|
| 8 |
+
Final state: {final_state}
|
| 9 |
+
|
| 10 |
+
Write "video_prompt": one paragraph of 100 to 170 words for the image-to-video model. Start with "From a fixed first-person camera perspective, based strictly on the initial image, the viewer's two human hands are resting on the near edge of the table." Describe the object and the target with their colours and positions as in image 1. Include verbatim: "Generate only these two human arms, with no extra limbs and no robotic arms. Every object stays still unless the hand holds it." Then describe with "First, ... Next, ... Finally, ..." how the {hand} hand reaches the object, grasps it, moves it and releases it at the target while the other hand stays still, then returns to rest without touching anything. The camera never moves. End with "The final frame shows {final_state}, with both hands resting at the near edge." The motion is realistic and smooth and completes within about {duration_s} seconds.
|
| 11 |
+
|
| 12 |
+
Also write "caption": two sentences describing the human video.
|
| 13 |
+
|
| 14 |
+
Return one JSON object with keys "video_prompt" and "caption".
|
human_video.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Robot → human demonstration videos, one per annotated subtask.
|
| 2 |
+
|
| 3 |
+
Mirrors LeRobot's ``human_video`` annotation module (``--human_video.enabled``), self-contained so
|
| 4 |
+
that it runs on the pinned pipeline bundle:
|
| 5 |
+
|
| 6 |
+
1. the VLM reads frames of the robot segment and writes the human task and an image-edit prompt;
|
| 7 |
+
2. Qwen-Image-Edit turns the segment's first full-resolution frame into a first-person human view;
|
| 8 |
+
3. the VLM writes a step-by-step video prompt from the edited frame;
|
| 9 |
+
4. MiniMax-H3 (480P) animates it.
|
| 10 |
+
|
| 11 |
+
Media requests go through Hugging Face Inference Providers (fal) with the signed-in user's token and
|
| 12 |
+
reserve their quoted price from the job's spending target before they start.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import io
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from urllib.parse import urlparse
|
| 21 |
+
|
| 22 |
+
from model_catalog import human_video_profile
|
| 23 |
+
from PIL import Image
|
| 24 |
+
from provider import BudgetExceededError, DeadlineExceededError
|
| 25 |
+
|
| 26 |
+
from lerobot.annotations.steerable_pipeline.frames import to_image_blocks
|
| 27 |
+
|
| 28 |
+
PLAN_PROMPT = Path(__file__).with_name("human-video-plan.txt").read_text()
|
| 29 |
+
VIDEO_PROMPT = Path(__file__).with_name("human-video-prompt.txt").read_text()
|
| 30 |
+
# A video takes about three minutes; do not start one the runtime limit would cut off.
|
| 31 |
+
VIDEO_SECONDS_NEEDED = 420
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def png_bytes(image):
|
| 35 |
+
buffer = io.BytesIO()
|
| 36 |
+
image.convert("RGB").save(buffer, format="PNG")
|
| 37 |
+
return buffer.getvalue()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def subtask_spans(record, staging):
|
| 41 |
+
"""``{text, start, end}`` from the staged subtask rows; each span ends at the next start."""
|
| 42 |
+
rows = [r for r in staging.read("plan") if r.get("style") == "subtask"] if staging.has("plan") else []
|
| 43 |
+
rows = sorted(rows, key=lambda r: float(r["timestamp"]))
|
| 44 |
+
end_of_episode = float(record.frame_timestamps[-1])
|
| 45 |
+
spans = []
|
| 46 |
+
for i, row in enumerate(rows):
|
| 47 |
+
start = float(row["timestamp"])
|
| 48 |
+
end = float(rows[i + 1]["timestamp"]) if i + 1 < len(rows) else end_of_episode
|
| 49 |
+
if end > start and row.get("content"):
|
| 50 |
+
spans.append({"text": str(row["content"]), "start": start, "end": end})
|
| 51 |
+
return spans
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class FalMedia:
|
| 55 |
+
"""Qwen-Image-Edit + MiniMax-H3 on fal through the HF router, charged to ``budget``."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, token, budget, remaining_time, profile=None, concurrency=None, poll_interval=2.0):
|
| 58 |
+
self.token, self.budget, self.remaining_time = token, budget, remaining_time
|
| 59 |
+
self.profile = profile or human_video_profile()
|
| 60 |
+
self.poll_interval = poll_interval
|
| 61 |
+
# Job-wide limit on simultaneous media requests, shared by every episode thread.
|
| 62 |
+
self.concurrency = max(1, min(int(concurrency or self.profile["concurrency"]), 32))
|
| 63 |
+
self.semaphore = threading.BoundedSemaphore(self.concurrency)
|
| 64 |
+
|
| 65 |
+
def edit_image(self, image, prompt):
|
| 66 |
+
from huggingface_hub import InferenceClient
|
| 67 |
+
|
| 68 |
+
self.remaining_time()
|
| 69 |
+
with self.semaphore:
|
| 70 |
+
reservation = self.budget.reserve_media(self.profile["edit_usd"])
|
| 71 |
+
client = InferenceClient(provider=self.profile["provider"], api_key=self.token)
|
| 72 |
+
edited = client.image_to_image(
|
| 73 |
+
png_bytes(image),
|
| 74 |
+
prompt=prompt,
|
| 75 |
+
model=self.profile["edit_model"],
|
| 76 |
+
guidance_scale=self.profile["edit_guidance_scale"],
|
| 77 |
+
)
|
| 78 |
+
self.budget.record_media(reservation)
|
| 79 |
+
# fal returns a square image; keep the camera's size and aspect ratio.
|
| 80 |
+
return edited.convert("RGB").resize(image.size, Image.LANCZOS)
|
| 81 |
+
|
| 82 |
+
def image_to_video(self, image, prompt):
|
| 83 |
+
from huggingface_hub.inference._providers.fal_ai import FalAIImageToVideoTask
|
| 84 |
+
from huggingface_hub.utils import get_session, hf_raise_for_status
|
| 85 |
+
|
| 86 |
+
if self.remaining_time() < VIDEO_SECONDS_NEEDED:
|
| 87 |
+
raise DeadlineExceededError("Not enough runtime left for another video.")
|
| 88 |
+
# InferenceClient has no image-text-to-video method yet; fal's image-to-video helper speaks
|
| 89 |
+
# the same queue protocol once its task matches the model's mapping.
|
| 90 |
+
helper = FalAIImageToVideoTask()
|
| 91 |
+
helper.task = self.profile["video_task"]
|
| 92 |
+
params = {
|
| 93 |
+
"prompt": prompt,
|
| 94 |
+
"resolution": self.profile["resolution"],
|
| 95 |
+
"duration": self.profile["duration_s"],
|
| 96 |
+
}
|
| 97 |
+
session = get_session()
|
| 98 |
+
with self.semaphore:
|
| 99 |
+
request = helper.prepare_request(
|
| 100 |
+
inputs=png_bytes(image),
|
| 101 |
+
parameters=params,
|
| 102 |
+
headers={},
|
| 103 |
+
model=self.profile["video_model"],
|
| 104 |
+
api_key=self.token,
|
| 105 |
+
)
|
| 106 |
+
reservation = self.budget.reserve_media(self.profile["video_usd"])
|
| 107 |
+
response = session.post(request.url, json=request.json, headers=request.headers, timeout=60)
|
| 108 |
+
hf_raise_for_status(response)
|
| 109 |
+
queued = response.json()
|
| 110 |
+
if not queued.get("request_id") or not queued.get("response_url"):
|
| 111 |
+
raise RuntimeError("The video provider did not queue the request.")
|
| 112 |
+
parsed = urlparse(request.url)
|
| 113 |
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
| 114 |
+
if parsed.netloc == "router.huggingface.co":
|
| 115 |
+
base += "/fal-ai"
|
| 116 |
+
query = f"?{parsed.query}" if parsed.query else ""
|
| 117 |
+
path = urlparse(queued["response_url"]).path
|
| 118 |
+
status = queued.get("status")
|
| 119 |
+
while status != "COMPLETED":
|
| 120 |
+
if status in ("FAILED", "ERROR", "CANCELLED"):
|
| 121 |
+
raise RuntimeError(f"The video provider reported {status}.")
|
| 122 |
+
self.remaining_time()
|
| 123 |
+
time.sleep(self.poll_interval)
|
| 124 |
+
polled = session.get(f"{base}{path}/status{query}", headers=request.headers, timeout=30)
|
| 125 |
+
hf_raise_for_status(polled)
|
| 126 |
+
status = polled.json().get("status")
|
| 127 |
+
result = session.get(f"{base}{path}{query}", headers=request.headers, timeout=60)
|
| 128 |
+
hf_raise_for_status(result)
|
| 129 |
+
url = result.json()["video"]["url"]
|
| 130 |
+
# The result is a public CDN link; the token is never sent there.
|
| 131 |
+
video = session.get(url, timeout=120)
|
| 132 |
+
hf_raise_for_status(video)
|
| 133 |
+
self.budget.record_media(reservation)
|
| 134 |
+
return video.content
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _nearest(timestamps, t):
|
| 138 |
+
return min(timestamps, key=lambda ts: abs(ts - t))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def generate_human_videos(record, staging, vlm, frames, full_frames, media, out_dir, max_segments):
|
| 142 |
+
"""Generate the episode's videos (``max_segments`` 0 = every subtask); returns entries with local ``files``.
|
| 143 |
+
|
| 144 |
+
A failed segment is recorded and the others continue. Reaching the spending target or runtime
|
| 145 |
+
limit stops the remaining segments without failing the (already validated) annotations.
|
| 146 |
+
"""
|
| 147 |
+
spans = subtask_spans(record, staging)
|
| 148 |
+
if max_segments:
|
| 149 |
+
spans = spans[:max_segments]
|
| 150 |
+
if not spans:
|
| 151 |
+
return []
|
| 152 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
ts = record.frame_timestamps
|
| 154 |
+
contexts = []
|
| 155 |
+
for span in spans:
|
| 156 |
+
samples = [span["start"] + (span["end"] - span["start"]) * i / 5 for i in range(6)]
|
| 157 |
+
contexts.append(frames.frames_at(record, [_nearest(ts, t) for t in samples]))
|
| 158 |
+
plans = vlm.generate_json(
|
| 159 |
+
[
|
| 160 |
+
[
|
| 161 |
+
{
|
| 162 |
+
"role": "user",
|
| 163 |
+
"content": [
|
| 164 |
+
*to_image_blocks(ctx),
|
| 165 |
+
{
|
| 166 |
+
"type": "text",
|
| 167 |
+
"text": PLAN_PROMPT.format(
|
| 168 |
+
n=len(ctx),
|
| 169 |
+
duration=span["end"] - span["start"],
|
| 170 |
+
subtask=span["text"],
|
| 171 |
+
task=record.episode_task,
|
| 172 |
+
),
|
| 173 |
+
},
|
| 174 |
+
],
|
| 175 |
+
}
|
| 176 |
+
]
|
| 177 |
+
for span, ctx in zip(spans, contexts, strict=True)
|
| 178 |
+
]
|
| 179 |
+
)
|
| 180 |
+
stop = threading.Event()
|
| 181 |
+
profile = media.profile
|
| 182 |
+
|
| 183 |
+
def segment(k):
|
| 184 |
+
span, ctx, plan = spans[k], contexts[k], plans[k]
|
| 185 |
+
stem = out_dir / f"segment_{k:03d}"
|
| 186 |
+
entry = {
|
| 187 |
+
"source_episode_index": record.episode_index,
|
| 188 |
+
"segment_index": k,
|
| 189 |
+
"subtask": span["text"],
|
| 190 |
+
"start_timestamp": span["start"],
|
| 191 |
+
"end_timestamp": span["end"],
|
| 192 |
+
"provider": profile["provider"],
|
| 193 |
+
"edit_model": profile["edit_model"],
|
| 194 |
+
"video_model": profile["video_model"],
|
| 195 |
+
"resolution": profile["resolution"],
|
| 196 |
+
"files": {},
|
| 197 |
+
}
|
| 198 |
+
if stop.is_set():
|
| 199 |
+
entry["status"] = "skipped"
|
| 200 |
+
return entry
|
| 201 |
+
try:
|
| 202 |
+
if not isinstance(plan, dict) or not plan.get("edit_prompt"):
|
| 203 |
+
raise ValueError("The model returned no human-video plan.")
|
| 204 |
+
robot = full_frames.frames_at(record, [_nearest(ts, span["start"])])[0].convert("RGB")
|
| 205 |
+
robot.save(f"{stem}_robot_frame.png")
|
| 206 |
+
entry["files"]["robot_frame"] = f"{stem}_robot_frame.png"
|
| 207 |
+
entry.update(human_task=plan.get("human_task"), edit_prompt=plan["edit_prompt"])
|
| 208 |
+
edited = media.edit_image(robot, str(plan["edit_prompt"]))
|
| 209 |
+
edited.save(f"{stem}_first_frame.png")
|
| 210 |
+
entry["files"]["first_frame"] = f"{stem}_first_frame.png"
|
| 211 |
+
text = VIDEO_PROMPT.format(
|
| 212 |
+
human_task=plan.get("human_task", ""),
|
| 213 |
+
object=plan.get("object", ""),
|
| 214 |
+
target=plan.get("target", ""),
|
| 215 |
+
steps="; ".join(map(str, plan.get("steps") or [])),
|
| 216 |
+
final_state=plan.get("final_state", "the task completed"),
|
| 217 |
+
hand=plan.get("hand", "right"),
|
| 218 |
+
duration_s=profile["duration_s"],
|
| 219 |
+
)
|
| 220 |
+
content = [{"type": "image", "image": edited}, *to_image_blocks(ctx)]
|
| 221 |
+
messages = [{"role": "user", "content": [*content, {"type": "text", "text": text}]}]
|
| 222 |
+
written = vlm.generate_json([messages])[0]
|
| 223 |
+
if not isinstance(written, dict) or not written.get("video_prompt"):
|
| 224 |
+
raise ValueError("The model returned no video prompt.")
|
| 225 |
+
entry.update(video_prompt=written["video_prompt"], caption=written.get("caption"))
|
| 226 |
+
Path(f"{stem}.mp4").write_bytes(media.image_to_video(edited, str(written["video_prompt"])))
|
| 227 |
+
entry["files"]["video"] = f"{stem}.mp4"
|
| 228 |
+
entry["status"] = "completed"
|
| 229 |
+
except BudgetExceededError as exc:
|
| 230 |
+
stop.set()
|
| 231 |
+
entry["status"] = "time_stopped" if isinstance(exc, DeadlineExceededError) else "budget_stopped"
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
entry.update(status="failed", error_type=type(exc).__name__)
|
| 234 |
+
return entry
|
| 235 |
+
|
| 236 |
+
with ThreadPoolExecutor(max_workers=min(len(spans), getattr(media, "concurrency", 1))) as pool:
|
| 237 |
+
return list(pool.map(segment, range(len(spans))))
|
model_catalog.py
CHANGED
|
@@ -206,6 +206,36 @@ MODELS = {
|
|
| 206 |
|
| 207 |
UNAVAILABLE = []
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
def model_profile(key=DEFAULT_MODEL):
|
| 211 |
if key not in MODELS:
|
|
@@ -213,9 +243,23 @@ def model_profile(key=DEFAULT_MODEL):
|
|
| 213 |
return deepcopy(MODELS[key])
|
| 214 |
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def public_models():
|
| 217 |
return {
|
| 218 |
"default": DEFAULT_MODEL,
|
| 219 |
"models": [model_profile(k) for k in MODELS],
|
| 220 |
"unavailable": deepcopy(UNAVAILABLE),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
}
|
|
|
|
| 206 |
|
| 207 |
UNAVAILABLE = []
|
| 208 |
|
| 209 |
+
# Robot → human demonstration videos. Both media models run on fal through HF Inference Providers
|
| 210 |
+
# and are billed per request to the signed-in account, so the Space reserves a fixed quoted price.
|
| 211 |
+
HUMAN_VIDEO = {
|
| 212 |
+
"provider": "fal-ai",
|
| 213 |
+
"provider_name": "fal",
|
| 214 |
+
"edit_model": "Qwen/Qwen-Image-Edit-2509",
|
| 215 |
+
# Lower guidance keeps the scene's colours; fal's default oversaturates.
|
| 216 |
+
"edit_guidance_scale": 1.5,
|
| 217 |
+
"video_model": "MiniMaxAI/MiniMax-H3",
|
| 218 |
+
# MiniMax-H3 is mapped under the image-text-to-video task on fal.
|
| 219 |
+
"video_task": "image-text-to-video",
|
| 220 |
+
"resolution": "480P",
|
| 221 |
+
"duration_s": 5,
|
| 222 |
+
# $0.03 per megapixel; fal returns about 1 MP, reserved as 1.33 MP.
|
| 223 |
+
"edit_usd": 0.04,
|
| 224 |
+
# $0.05 per second at 480p; the clip is 5 s, reserved as 6 s.
|
| 225 |
+
"video_usd": 0.30,
|
| 226 |
+
# Default simultaneous media requests per job (user-selectable up to 32).
|
| 227 |
+
"concurrency": 8,
|
| 228 |
+
# Estimation only: the action-phase prompt yields about one subtask per 5 s of episode
|
| 229 |
+
# (the same rate the memory estimate uses), and a video takes up to ~3 min on fal.
|
| 230 |
+
"seconds_per_subtask": 5,
|
| 231 |
+
"minutes_per_video": 3,
|
| 232 |
+
"checked_at": "2026-09-30",
|
| 233 |
+
"pricing_url": "https://fal.ai/models/minimax/h3/image-to-video",
|
| 234 |
+
}
|
| 235 |
+
# 0 = one video for every subtask.
|
| 236 |
+
HUMAN_VIDEO_SEGMENT_CHOICES = (0, 1, 2, 3, 5, 8)
|
| 237 |
+
HUMAN_VIDEO_PARALLEL_CHOICES = (1, 4, 8, 16, 32)
|
| 238 |
+
|
| 239 |
|
| 240 |
def model_profile(key=DEFAULT_MODEL):
|
| 241 |
if key not in MODELS:
|
|
|
|
| 243 |
return deepcopy(MODELS[key])
|
| 244 |
|
| 245 |
|
| 246 |
+
def human_video_profile():
|
| 247 |
+
return deepcopy(HUMAN_VIDEO)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def human_video_segment_cost(profile=None):
|
| 251 |
+
profile = profile or HUMAN_VIDEO
|
| 252 |
+
return profile["edit_usd"] + profile["video_usd"]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
def public_models():
|
| 256 |
return {
|
| 257 |
"default": DEFAULT_MODEL,
|
| 258 |
"models": [model_profile(k) for k in MODELS],
|
| 259 |
"unavailable": deepcopy(UNAVAILABLE),
|
| 260 |
+
"human_video": {
|
| 261 |
+
**human_video_profile(),
|
| 262 |
+
"segment_choices": list(HUMAN_VIDEO_SEGMENT_CHOICES),
|
| 263 |
+
"parallel_choices": list(HUMAN_VIDEO_PARALLEL_CHOICES),
|
| 264 |
+
},
|
| 265 |
}
|
provider.py
CHANGED
|
@@ -37,6 +37,8 @@ class Budget:
|
|
| 37 |
self.context_length = context_length
|
| 38 |
self.pricing_tiers = sorted(pricing_tiers or [], key=lambda t: t["min_input_tokens"])
|
| 39 |
self.input_tokens = self.output_tokens = self.cached_tokens = self.attempts = self.rate_limits = 0
|
|
|
|
|
|
|
| 40 |
self.lock = threading.Lock()
|
| 41 |
|
| 42 |
def reserve(self, max_tokens):
|
|
@@ -54,6 +56,22 @@ class Budget:
|
|
| 54 |
output_rate = max([self.output_rate] + [t["output_rate"] for t in self.pricing_tiers])
|
| 55 |
return (self.context_length * input_rate + max_tokens * output_rate) / 1e6
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def reconcile(self, reservation, usage):
|
| 58 |
if not isinstance(usage, dict):
|
| 59 |
return # Unknown charges keep the complete reservation.
|
|
@@ -94,6 +112,9 @@ class Budget:
|
|
| 94 |
"attempted_requests": self.attempts,
|
| 95 |
"requests_without_usage": self.attempts - self.calls,
|
| 96 |
"rate_limited_requests": self.rate_limits,
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
|
|
|
|
| 37 |
self.context_length = context_length
|
| 38 |
self.pricing_tiers = sorted(pricing_tiers or [], key=lambda t: t["min_input_tokens"])
|
| 39 |
self.input_tokens = self.output_tokens = self.cached_tokens = self.attempts = self.rate_limits = 0
|
| 40 |
+
self.media_attempts = self.media_completed = 0
|
| 41 |
+
self.media_usd = 0.0
|
| 42 |
self.lock = threading.Lock()
|
| 43 |
|
| 44 |
def reserve(self, max_tokens):
|
|
|
|
| 56 |
output_rate = max([self.output_rate] + [t["output_rate"] for t in self.pricing_tiers])
|
| 57 |
return (self.context_length * input_rate + max_tokens * output_rate) / 1e6
|
| 58 |
|
| 59 |
+
def reserve_media(self, amount):
|
| 60 |
+
"""Reserve a fixed-price media request (image edit or video). Never refunded."""
|
| 61 |
+
with self.lock:
|
| 62 |
+
if self.reserved + amount > self.limit:
|
| 63 |
+
raise BudgetExceededError("Spending target reached; completed results are saved.")
|
| 64 |
+
self.reserved += amount
|
| 65 |
+
self.media_attempts += 1
|
| 66 |
+
return amount
|
| 67 |
+
|
| 68 |
+
def record_media(self, amount):
|
| 69 |
+
"""A media request returned its output; count its quoted price as measured."""
|
| 70 |
+
with self.lock:
|
| 71 |
+
self.measured += amount
|
| 72 |
+
self.media_usd += amount
|
| 73 |
+
self.media_completed += 1
|
| 74 |
+
|
| 75 |
def reconcile(self, reservation, usage):
|
| 76 |
if not isinstance(usage, dict):
|
| 77 |
return # Unknown charges keep the complete reservation.
|
|
|
|
| 112 |
"attempted_requests": self.attempts,
|
| 113 |
"requests_without_usage": self.attempts - self.calls,
|
| 114 |
"rate_limited_requests": self.rate_limits,
|
| 115 |
+
"media_requests": self.media_attempts,
|
| 116 |
+
"completed_media_requests": self.media_completed,
|
| 117 |
+
"media_usd": self.media_usd,
|
| 118 |
}
|
| 119 |
|
| 120 |
|
server.py
CHANGED
|
@@ -4,6 +4,7 @@ import base64
|
|
| 4 |
import hashlib
|
| 5 |
import json
|
| 6 |
import os
|
|
|
|
| 7 |
import secrets
|
| 8 |
import tempfile
|
| 9 |
import threading
|
|
@@ -14,7 +15,7 @@ from urllib.parse import urlencode
|
|
| 14 |
|
| 15 |
import requests
|
| 16 |
from fastapi import FastAPI, HTTPException, Request
|
| 17 |
-
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 18 |
from fastapi.staticfiles import StaticFiles
|
| 19 |
from huggingface_hub import HfApi, hf_hub_download
|
| 20 |
from model_catalog import DEFAULT_MODEL, public_models
|
|
@@ -43,12 +44,15 @@ class EstimateInput(DatasetInput):
|
|
| 43 |
model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
|
| 44 |
start: int = Field(default=0, ge=0)
|
| 45 |
count: int = Field(default=100, ge=0)
|
| 46 |
-
features: list[str] = Field(min_length=1, max_length=
|
| 47 |
camera: str | None = Field(default=None, max_length=200)
|
| 48 |
hz: float = 0.1
|
| 49 |
hours: int = 4
|
| 50 |
mode: Literal["Annotated dataset copy"] = COPY
|
| 51 |
prompts: dict[str, str] = Field(default_factory=dict, max_length=7)
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class PromptInput(BaseModel):
|
|
@@ -265,6 +269,8 @@ def calculate(options: EstimateInput, request: Request):
|
|
| 265 |
current.get("token", False),
|
| 266 |
prompts=options.prompts,
|
| 267 |
model_key=options.model,
|
|
|
|
|
|
|
| 268 |
)
|
| 269 |
except Exception as exc:
|
| 270 |
raise HTTPException(400, safe_error(exc)) from None
|
|
@@ -387,27 +393,40 @@ def cancel(job_id: str, request: Request):
|
|
| 387 |
return {"ok": True}
|
| 388 |
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
@app.get("/api/jobs/{job_id}/progress")
|
| 391 |
def progress(job_id: str, request: Request):
|
| 392 |
current = session(request, required=True)
|
| 393 |
try:
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
output = output_repo(job, current["user"]["name"])
|
| 397 |
-
if (job.labels or {}).get("app") != "lerobot-annotation-studio" or not output.startswith(
|
| 398 |
-
current["user"]["name"] + "/"
|
| 399 |
-
):
|
| 400 |
-
raise ValueError("This job has no Annotation Studio result.")
|
| 401 |
-
with tempfile.TemporaryDirectory() as temp:
|
| 402 |
-
path = hf_hub_download(
|
| 403 |
-
output,
|
| 404 |
-
"annotation_studio/run.json",
|
| 405 |
-
repo_type="dataset",
|
| 406 |
-
token=current["token"],
|
| 407 |
-
cache_dir=temp,
|
| 408 |
-
)
|
| 409 |
-
report = json.loads(Path(path).read_text())
|
| 410 |
episodes = report.get("episodes", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
return {
|
| 412 |
"status": "interrupted"
|
| 413 |
if job.status.stage in {"COMPLETED", "ERROR", "CANCELED", "DELETED"}
|
|
@@ -423,11 +442,61 @@ def progress(job_id: str, request: Request):
|
|
| 423 |
"annotation_url": annotation_url(report),
|
| 424 |
"output_mode": report.get("manifest", {}).get("mode"),
|
| 425 |
"model": report.get("manifest", {}).get("model"),
|
|
|
|
| 426 |
}
|
| 427 |
except Exception as exc:
|
| 428 |
raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
|
| 429 |
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
@app.get("/health")
|
| 432 |
def health():
|
| 433 |
return {"status": "ok", "service": "lerobot-annotation-studio"}
|
|
|
|
| 4 |
import hashlib
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
+
import re
|
| 8 |
import secrets
|
| 9 |
import tempfile
|
| 10 |
import threading
|
|
|
|
| 15 |
|
| 16 |
import requests
|
| 17 |
from fastapi import FastAPI, HTTPException, Request
|
| 18 |
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
| 19 |
from fastapi.staticfiles import StaticFiles
|
| 20 |
from huggingface_hub import HfApi, hf_hub_download
|
| 21 |
from model_catalog import DEFAULT_MODEL, public_models
|
|
|
|
| 44 |
model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
|
| 45 |
start: int = Field(default=0, ge=0)
|
| 46 |
count: int = Field(default=100, ge=0)
|
| 47 |
+
features: list[str] = Field(min_length=1, max_length=7)
|
| 48 |
camera: str | None = Field(default=None, max_length=200)
|
| 49 |
hz: float = 0.1
|
| 50 |
hours: int = 4
|
| 51 |
mode: Literal["Annotated dataset copy"] = COPY
|
| 52 |
prompts: dict[str, str] = Field(default_factory=dict, max_length=7)
|
| 53 |
+
# 0 = every subtask.
|
| 54 |
+
human_video_segments: int | None = Field(default=None, ge=0, le=8)
|
| 55 |
+
human_video_parallel: int = Field(default=8, ge=1, le=32)
|
| 56 |
|
| 57 |
|
| 58 |
class PromptInput(BaseModel):
|
|
|
|
| 269 |
current.get("token", False),
|
| 270 |
prompts=options.prompts,
|
| 271 |
model_key=options.model,
|
| 272 |
+
human_video_segments=options.human_video_segments,
|
| 273 |
+
human_video_parallel=options.human_video_parallel,
|
| 274 |
)
|
| 275 |
except Exception as exc:
|
| 276 |
raise HTTPException(400, safe_error(exc)) from None
|
|
|
|
| 393 |
return {"ok": True}
|
| 394 |
|
| 395 |
|
| 396 |
+
def studio_output(job_id, current):
|
| 397 |
+
"""The job and its output dataset, only for Annotation Studio jobs in the user's namespace."""
|
| 398 |
+
api = HfApi(token=current["token"])
|
| 399 |
+
job = api.inspect_job(job_id=job_id, namespace=current["user"]["name"])
|
| 400 |
+
output = output_repo(job, current["user"]["name"])
|
| 401 |
+
if (job.labels or {}).get("app") != "lerobot-annotation-studio" or not output.startswith(
|
| 402 |
+
current["user"]["name"] + "/"
|
| 403 |
+
):
|
| 404 |
+
raise ValueError("This job has no Annotation Studio result.")
|
| 405 |
+
return job, output
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def read_output_file(output, path, token):
|
| 409 |
+
with tempfile.TemporaryDirectory() as temp:
|
| 410 |
+
local = hf_hub_download(output, path, repo_type="dataset", token=token, cache_dir=temp)
|
| 411 |
+
return Path(local).read_bytes()
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
HUMAN_VIDEO_FILE = re.compile(
|
| 415 |
+
r"annotation_studio/human_videos/episode_\d{6}/segment_\d{3}(_robot_frame\.png|_first_frame\.png|\.mp4)"
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
@app.get("/api/jobs/{job_id}/progress")
|
| 420 |
def progress(job_id: str, request: Request):
|
| 421 |
current = session(request, required=True)
|
| 422 |
try:
|
| 423 |
+
job, output = studio_output(job_id, current)
|
| 424 |
+
report = json.loads(read_output_file(output, "annotation_studio/run.json", current["token"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
episodes = report.get("episodes", [])
|
| 426 |
+
videos = {}
|
| 427 |
+
for episode in episodes:
|
| 428 |
+
for status, count in (episode.get("human_videos") or {}).items():
|
| 429 |
+
videos[status] = videos.get(status, 0) + count
|
| 430 |
return {
|
| 431 |
"status": "interrupted"
|
| 432 |
if job.status.stage in {"COMPLETED", "ERROR", "CANCELED", "DELETED"}
|
|
|
|
| 442 |
"annotation_url": annotation_url(report),
|
| 443 |
"output_mode": report.get("manifest", {}).get("mode"),
|
| 444 |
"model": report.get("manifest", {}).get("model"),
|
| 445 |
+
"human_videos": videos if (report.get("manifest", {}).get("human_video")) else None,
|
| 446 |
}
|
| 447 |
except Exception as exc:
|
| 448 |
raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
|
| 449 |
|
| 450 |
|
| 451 |
+
@app.get("/api/jobs/{job_id}/human-videos")
|
| 452 |
+
def human_videos(job_id: str, request: Request):
|
| 453 |
+
current = session(request, required=True)
|
| 454 |
+
try:
|
| 455 |
+
_, output = studio_output(job_id, current)
|
| 456 |
+
index = json.loads(
|
| 457 |
+
read_output_file(output, "annotation_studio/human_videos/index.json", current["token"])
|
| 458 |
+
)
|
| 459 |
+
except Exception as exc:
|
| 460 |
+
raise HTTPException(400, "No human demo videos saved yet.") from exc
|
| 461 |
+
media = f"/api/jobs/{job_id}/media?path="
|
| 462 |
+
return [
|
| 463 |
+
{
|
| 464 |
+
key: entry.get(key)
|
| 465 |
+
for key in (
|
| 466 |
+
"episode_index",
|
| 467 |
+
"source_episode_index",
|
| 468 |
+
"segment_index",
|
| 469 |
+
"subtask",
|
| 470 |
+
"human_task",
|
| 471 |
+
"caption",
|
| 472 |
+
"start_timestamp",
|
| 473 |
+
"end_timestamp",
|
| 474 |
+
"status",
|
| 475 |
+
)
|
| 476 |
+
}
|
| 477 |
+
| {
|
| 478 |
+
kind: media + entry[f"{kind}_path"]
|
| 479 |
+
for kind in ("robot_frame", "first_frame", "video")
|
| 480 |
+
if HUMAN_VIDEO_FILE.fullmatch(entry.get(f"{kind}_path") or "")
|
| 481 |
+
}
|
| 482 |
+
for entry in index[:60]
|
| 483 |
+
]
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@app.get("/api/jobs/{job_id}/media")
|
| 487 |
+
def human_video_media(job_id: str, path: str, request: Request):
|
| 488 |
+
"""Stream a generated video or frame from the user's (possibly private) output dataset."""
|
| 489 |
+
current = session(request, required=True)
|
| 490 |
+
if not HUMAN_VIDEO_FILE.fullmatch(path):
|
| 491 |
+
raise HTTPException(400, "Unsupported media path.")
|
| 492 |
+
try:
|
| 493 |
+
_, output = studio_output(job_id, current)
|
| 494 |
+
content = read_output_file(output, path, current["token"])
|
| 495 |
+
except Exception as exc:
|
| 496 |
+
raise HTTPException(404, "Media not found.") from exc
|
| 497 |
+
return Response(content, media_type="video/mp4" if path.endswith(".mp4") else "image/png")
|
| 498 |
+
|
| 499 |
+
|
| 500 |
@app.get("/health")
|
| 501 |
def health():
|
| 502 |
return {"status": "ok", "service": "lerobot-annotation-studio"}
|
studio.py
CHANGED
|
@@ -14,7 +14,14 @@ from pathlib import Path, PurePosixPath
|
|
| 14 |
import pyarrow.parquet as pq
|
| 15 |
from huggingface_hub import HfApi, hf_hub_download
|
| 16 |
from huggingface_hub.utils import validate_repo_id
|
| 17 |
-
from model_catalog import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from prompt_config import TEMPLATES, validate_prompts
|
| 19 |
|
| 20 |
MODEL = model_profile()["route"]
|
|
@@ -26,7 +33,10 @@ FEATURES = {
|
|
| 26 |
"Task rephrasings": "task_aug",
|
| 27 |
"Interjections & speech": "interjection",
|
| 28 |
"Visual questions": "vqa",
|
|
|
|
|
|
|
| 29 |
}
|
|
|
|
| 30 |
COPY = "Annotated dataset copy"
|
| 31 |
EXPORT = "Annotation files only" # Historical reports only; new runs always write Parquet.
|
| 32 |
|
|
@@ -151,11 +161,28 @@ def read_selection(dataset, start, count, token=False, revision=None, cache_dir=
|
|
| 151 |
}
|
| 152 |
|
| 153 |
|
| 154 |
-
def estimate(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
profile = model_profile(model_key)
|
| 156 |
prompts = validate_prompts(prompts or {}, features)
|
| 157 |
if not features or any(f not in FEATURES for f in features):
|
| 158 |
raise ValueError("Choose at least one annotation type.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
|
| 160 |
raise ValueError("Choose a supported timeout and visual-question frequency.")
|
| 161 |
n, seconds = len(selection["episodes"]), selection["seconds"]
|
|
@@ -185,6 +212,24 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
|
|
| 185 |
inputs += ticks * 1500
|
| 186 |
outputs += ticks * 200
|
| 187 |
calls += ticks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
if "plan_render" in prompts:
|
| 189 |
plan_calls = max(n, seconds / 5) + (n * 3 if "Interjections & speech" in features else 0)
|
| 190 |
inputs += plan_calls * 1500
|
|
@@ -207,9 +252,16 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
|
|
| 207 |
cpu_max = math.ceil(hours * 60 + 1) * cpu_rate
|
| 208 |
# Range includes retries, grounding, variable segment counts and recovery.
|
| 209 |
return {
|
| 210 |
-
"low": inference * 0.6 + cpu_max,
|
| 211 |
-
"high": inference * 2.5 + cpu_max,
|
| 212 |
"inference": inference,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
"cpu_max": cpu_max,
|
| 214 |
"cpu_rate": cpu_rate,
|
| 215 |
"calls": math.ceil(calls),
|
|
@@ -226,10 +278,34 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
|
|
| 226 |
}
|
| 227 |
|
| 228 |
|
| 229 |
-
def fingerprint(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return hashlib.sha256(
|
| 231 |
json.dumps(
|
| 232 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
sort_keys=True,
|
| 234 |
).encode()
|
| 235 |
).hexdigest()
|
|
@@ -253,9 +329,13 @@ def prepare(
|
|
| 253 |
token=False,
|
| 254 |
prompts=None,
|
| 255 |
model_key=DEFAULT_MODEL,
|
|
|
|
|
|
|
| 256 |
):
|
| 257 |
profile = model_profile(model_key)
|
| 258 |
prompts = validate_prompts(prompts or {}, features)
|
|
|
|
|
|
|
| 259 |
if mode != COPY:
|
| 260 |
raise ValueError("Annotations must be saved in a LeRobot Parquet dataset. Refresh the Space.")
|
| 261 |
selection = read_selection(dataset, start, count, token)
|
|
@@ -266,7 +346,17 @@ def prepare(
|
|
| 266 |
hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == "cpu-upgrade")
|
| 267 |
if hardware.unit_label != "minute":
|
| 268 |
raise ValueError("HF changed its billing units; refresh the Space before launching.")
|
| 269 |
-
costs = estimate(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
return {
|
| 271 |
"selection": selection,
|
| 272 |
"features": list(features),
|
|
@@ -276,12 +366,42 @@ def prepare(
|
|
| 276 |
"prompts": prompts,
|
| 277 |
"costs": costs,
|
| 278 |
"model_profile": profile,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
"created": time.time(),
|
| 280 |
"nonce": uuid.uuid4().hex,
|
| 281 |
-
"fingerprint": fingerprint(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
def job_command(space, revision, timeout_minutes=None):
|
| 286 |
# Only trusted deployment values enter shell syntax. User options travel in JSON env.
|
| 287 |
validate_repo_id(space)
|
|
@@ -382,20 +502,23 @@ def launch(
|
|
| 382 |
* max([profile["input_rate"]] + [t["input_rate"] for t in profile.get("pricing_tiers", [])])
|
| 383 |
+ 4096 * max([profile["output_rate"]] + [t["output_rate"] for t in profile.get("pricing_tiers", [])])
|
| 384 |
) / 1e6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
if budget - plan["costs"]["cpu_max"] < minimum_reservation:
|
| 386 |
raise ValueError(
|
| 387 |
f"This model needs at least ${minimum_reservation + plan['costs']['cpu_max']:.2f} for one conservative request reservation. Increase the target or choose a lower-cost model."
|
| 388 |
)
|
| 389 |
-
|
| 390 |
-
profile["model_id"], expand=["inferenceProviderMapping"]
|
| 391 |
-
).inference_provider_mapping
|
| 392 |
-
hosted = (
|
| 393 |
-
mapping.get(profile["provider"])
|
| 394 |
-
if isinstance(mapping, dict)
|
| 395 |
-
else next((item for item in mapping or [] if item.provider == profile["provider"]), None)
|
| 396 |
-
)
|
| 397 |
-
if hosted is None or hosted.status != "live":
|
| 398 |
raise ValueError("The selected model is temporarily unavailable from this provider.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
space = space or os.environ.get("SPACE_ID")
|
| 400 |
if not space:
|
| 401 |
raise ValueError("Job launch is enabled only on the deployed Hugging Face Space.")
|
|
|
|
| 14 |
import pyarrow.parquet as pq
|
| 15 |
from huggingface_hub import HfApi, hf_hub_download
|
| 16 |
from huggingface_hub.utils import validate_repo_id
|
| 17 |
+
from model_catalog import (
|
| 18 |
+
DEFAULT_MODEL,
|
| 19 |
+
HUMAN_VIDEO_PARALLEL_CHOICES,
|
| 20 |
+
HUMAN_VIDEO_SEGMENT_CHOICES,
|
| 21 |
+
human_video_profile,
|
| 22 |
+
human_video_segment_cost,
|
| 23 |
+
model_profile,
|
| 24 |
+
)
|
| 25 |
from prompt_config import TEMPLATES, validate_prompts
|
| 26 |
|
| 27 |
MODEL = model_profile()["route"]
|
|
|
|
| 33 |
"Task rephrasings": "task_aug",
|
| 34 |
"Interjections & speech": "interjection",
|
| 35 |
"Visual questions": "vqa",
|
| 36 |
+
# Not a language style: videos are saved under annotation_studio/human_videos/.
|
| 37 |
+
"Human demo videos": "human_video",
|
| 38 |
}
|
| 39 |
+
HUMAN_VIDEOS = "Human demo videos"
|
| 40 |
COPY = "Annotated dataset copy"
|
| 41 |
EXPORT = "Annotation files only" # Historical reports only; new runs always write Parquet.
|
| 42 |
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
|
| 164 |
+
def estimate(
|
| 165 |
+
selection,
|
| 166 |
+
features,
|
| 167 |
+
vqa_hz,
|
| 168 |
+
hours,
|
| 169 |
+
cpu_rate,
|
| 170 |
+
prompts=None,
|
| 171 |
+
model_key=DEFAULT_MODEL,
|
| 172 |
+
human_video_segments=None,
|
| 173 |
+
human_video_parallel=None,
|
| 174 |
+
):
|
| 175 |
profile = model_profile(model_key)
|
| 176 |
prompts = validate_prompts(prompts or {}, features)
|
| 177 |
if not features or any(f not in FEATURES for f in features):
|
| 178 |
raise ValueError("Choose at least one annotation type.")
|
| 179 |
+
if HUMAN_VIDEOS in features:
|
| 180 |
+
if "Subtasks" not in features:
|
| 181 |
+
raise ValueError("Human demo videos are generated per subtask; also select Subtasks.")
|
| 182 |
+
if human_video_segments not in HUMAN_VIDEO_SEGMENT_CHOICES:
|
| 183 |
+
raise ValueError("Choose a supported number of videos per episode.")
|
| 184 |
+
if human_video_parallel not in HUMAN_VIDEO_PARALLEL_CHOICES:
|
| 185 |
+
raise ValueError("Choose a supported number of parallel videos.")
|
| 186 |
if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
|
| 187 |
raise ValueError("Choose a supported timeout and visual-question frequency.")
|
| 188 |
n, seconds = len(selection["episodes"]), selection["seconds"]
|
|
|
|
| 212 |
inputs += ticks * 1500
|
| 213 |
outputs += ticks * 200
|
| 214 |
calls += ticks
|
| 215 |
+
videos = videos_max = media = media_max = video_minutes = 0
|
| 216 |
+
if HUMAN_VIDEOS in features:
|
| 217 |
+
video_profile = human_video_profile()
|
| 218 |
+
# One video per subtask, and subtask counts scale with episode length: expect one per
|
| 219 |
+
# `seconds_per_subtask`, allow twice that in the upper bound, and apply the per-episode cap.
|
| 220 |
+
cap = human_video_segments or math.inf
|
| 221 |
+
per = video_profile["seconds_per_subtask"]
|
| 222 |
+
fps = selection["info"]["fps"]
|
| 223 |
+
lengths = [r["length"] / fps for r in selection["episodes"]]
|
| 224 |
+
videos = sum(min(cap, max(1.0, s / per)) for s in lengths)
|
| 225 |
+
videos_max = sum(min(cap, max(1, math.ceil(2 * s / per))) for s in lengths)
|
| 226 |
+
media = videos * human_video_segment_cost()
|
| 227 |
+
media_max = videos_max * human_video_segment_cost()
|
| 228 |
+
video_minutes = math.ceil(videos / human_video_parallel) * video_profile["minutes_per_video"]
|
| 229 |
+
# Two VLM calls per video: plan + edit prompt (6 frames), video prompt (edited frame + 6).
|
| 230 |
+
inputs += videos * (2600 + 3500)
|
| 231 |
+
outputs += videos * (300 + 350)
|
| 232 |
+
calls += videos * 2
|
| 233 |
if "plan_render" in prompts:
|
| 234 |
plan_calls = max(n, seconds / 5) + (n * 3 if "Interjections & speech" in features else 0)
|
| 235 |
inputs += plan_calls * 1500
|
|
|
|
| 252 |
cpu_max = math.ceil(hours * 60 + 1) * cpu_rate
|
| 253 |
# Range includes retries, grounding, variable segment counts and recovery.
|
| 254 |
return {
|
| 255 |
+
"low": inference * 0.6 + media * 0.6 + cpu_max,
|
| 256 |
+
"high": inference * 2.5 + media_max + cpu_max,
|
| 257 |
"inference": inference,
|
| 258 |
+
"media": media,
|
| 259 |
+
"media_max": media_max,
|
| 260 |
+
"videos": math.ceil(videos),
|
| 261 |
+
"videos_max": videos_max,
|
| 262 |
+
"video_minutes": video_minutes,
|
| 263 |
+
"video_parallel": human_video_parallel if videos else 0,
|
| 264 |
+
"segment_usd": human_video_segment_cost() if videos else 0,
|
| 265 |
"cpu_max": cpu_max,
|
| 266 |
"cpu_rate": cpu_rate,
|
| 267 |
"calls": math.ceil(calls),
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
|
| 281 |
+
def fingerprint(
|
| 282 |
+
dataset,
|
| 283 |
+
start,
|
| 284 |
+
count,
|
| 285 |
+
features,
|
| 286 |
+
camera,
|
| 287 |
+
vqa_hz,
|
| 288 |
+
hours,
|
| 289 |
+
mode,
|
| 290 |
+
model_key=DEFAULT_MODEL,
|
| 291 |
+
human_video_segments=None,
|
| 292 |
+
human_video_parallel=None,
|
| 293 |
+
):
|
| 294 |
return hashlib.sha256(
|
| 295 |
json.dumps(
|
| 296 |
+
[
|
| 297 |
+
dataset.strip(),
|
| 298 |
+
start,
|
| 299 |
+
count,
|
| 300 |
+
sorted(features),
|
| 301 |
+
camera,
|
| 302 |
+
vqa_hz,
|
| 303 |
+
hours,
|
| 304 |
+
mode,
|
| 305 |
+
model_key,
|
| 306 |
+
human_video_segments,
|
| 307 |
+
human_video_parallel,
|
| 308 |
+
],
|
| 309 |
sort_keys=True,
|
| 310 |
).encode()
|
| 311 |
).hexdigest()
|
|
|
|
| 329 |
token=False,
|
| 330 |
prompts=None,
|
| 331 |
model_key=DEFAULT_MODEL,
|
| 332 |
+
human_video_segments=None,
|
| 333 |
+
human_video_parallel=None,
|
| 334 |
):
|
| 335 |
profile = model_profile(model_key)
|
| 336 |
prompts = validate_prompts(prompts or {}, features)
|
| 337 |
+
if HUMAN_VIDEOS not in features:
|
| 338 |
+
human_video_segments = human_video_parallel = None
|
| 339 |
if mode != COPY:
|
| 340 |
raise ValueError("Annotations must be saved in a LeRobot Parquet dataset. Refresh the Space.")
|
| 341 |
selection = read_selection(dataset, start, count, token)
|
|
|
|
| 346 |
hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == "cpu-upgrade")
|
| 347 |
if hardware.unit_label != "minute":
|
| 348 |
raise ValueError("HF changed its billing units; refresh the Space before launching.")
|
| 349 |
+
costs = estimate(
|
| 350 |
+
selection,
|
| 351 |
+
features,
|
| 352 |
+
vqa_hz,
|
| 353 |
+
hours,
|
| 354 |
+
hardware.unit_cost_usd,
|
| 355 |
+
prompts,
|
| 356 |
+
model_key,
|
| 357 |
+
human_video_segments,
|
| 358 |
+
human_video_parallel,
|
| 359 |
+
)
|
| 360 |
return {
|
| 361 |
"selection": selection,
|
| 362 |
"features": list(features),
|
|
|
|
| 366 |
"prompts": prompts,
|
| 367 |
"costs": costs,
|
| 368 |
"model_profile": profile,
|
| 369 |
+
"human_video": {
|
| 370 |
+
# 0 = every subtask.
|
| 371 |
+
"segments_per_episode": human_video_segments,
|
| 372 |
+
"parallel": human_video_parallel,
|
| 373 |
+
"profile": human_video_profile(),
|
| 374 |
+
}
|
| 375 |
+
if HUMAN_VIDEOS in features
|
| 376 |
+
else None,
|
| 377 |
"created": time.time(),
|
| 378 |
"nonce": uuid.uuid4().hex,
|
| 379 |
+
"fingerprint": fingerprint(
|
| 380 |
+
dataset,
|
| 381 |
+
start,
|
| 382 |
+
count,
|
| 383 |
+
features,
|
| 384 |
+
camera,
|
| 385 |
+
vqa_hz,
|
| 386 |
+
hours,
|
| 387 |
+
mode,
|
| 388 |
+
model_key,
|
| 389 |
+
human_video_segments,
|
| 390 |
+
human_video_parallel,
|
| 391 |
+
),
|
| 392 |
}
|
| 393 |
|
| 394 |
|
| 395 |
+
def provider_live(api, model_id, provider):
|
| 396 |
+
mapping = api.model_info(model_id, expand=["inferenceProviderMapping"]).inference_provider_mapping
|
| 397 |
+
hosted = (
|
| 398 |
+
mapping.get(provider)
|
| 399 |
+
if isinstance(mapping, dict)
|
| 400 |
+
else next((item for item in mapping or [] if item.provider == provider), None)
|
| 401 |
+
)
|
| 402 |
+
return hosted is not None and hosted.status == "live"
|
| 403 |
+
|
| 404 |
+
|
| 405 |
def job_command(space, revision, timeout_minutes=None):
|
| 406 |
# Only trusted deployment values enter shell syntax. User options travel in JSON env.
|
| 407 |
validate_repo_id(space)
|
|
|
|
| 502 |
* max([profile["input_rate"]] + [t["input_rate"] for t in profile.get("pricing_tiers", [])])
|
| 503 |
+ 4096 * max([profile["output_rate"]] + [t["output_rate"] for t in profile.get("pricing_tiers", [])])
|
| 504 |
) / 1e6
|
| 505 |
+
human_video = plan.get("human_video")
|
| 506 |
+
if human_video:
|
| 507 |
+
if human_video["profile"] != human_video_profile():
|
| 508 |
+
raise ValueError("Video pricing changed. Calculate a fresh estimate.")
|
| 509 |
+
# One video must fit next to one conservative text reservation.
|
| 510 |
+
minimum_reservation += human_video_segment_cost()
|
| 511 |
if budget - plan["costs"]["cpu_max"] < minimum_reservation:
|
| 512 |
raise ValueError(
|
| 513 |
f"This model needs at least ${minimum_reservation + plan['costs']['cpu_max']:.2f} for one conservative request reservation. Increase the target or choose a lower-cost model."
|
| 514 |
)
|
| 515 |
+
if not provider_live(api, profile["model_id"], profile["provider"]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
raise ValueError("The selected model is temporarily unavailable from this provider.")
|
| 517 |
+
if human_video and not all(
|
| 518 |
+
provider_live(api, human_video["profile"][key], human_video["profile"]["provider"])
|
| 519 |
+
for key in ("edit_model", "video_model")
|
| 520 |
+
):
|
| 521 |
+
raise ValueError("The video models are temporarily unavailable from fal.")
|
| 522 |
space = space or os.environ.get("SPACE_ID")
|
| 523 |
if not space:
|
| 524 |
raise ValueError("Job launch is enabled only on the deployed Hugging Face Space.")
|
test_human_video.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human demo videos: pricing, launch checks, generation and media access, without paid requests."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
from unittest.mock import Mock
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 14 |
+
import human_video # noqa: E402
|
| 15 |
+
import studio # noqa: E402
|
| 16 |
+
from model_catalog import human_video_profile, human_video_segment_cost # noqa: E402
|
| 17 |
+
from provider import Budget, BudgetExceededError, DeadlineExceededError # noqa: E402
|
| 18 |
+
|
| 19 |
+
from lerobot.annotations.steerable_pipeline.reader import EpisodeRecord
|
| 20 |
+
from lerobot.annotations.steerable_pipeline.staging import EpisodeStaging
|
| 21 |
+
from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient
|
| 22 |
+
|
| 23 |
+
CAMERA = "observation.images.top"
|
| 24 |
+
PLAN = {
|
| 25 |
+
"human_task": "Pick up the cup with the right hand and place it on the table.",
|
| 26 |
+
"object": "white cup",
|
| 27 |
+
"target": "table",
|
| 28 |
+
"hand": "right",
|
| 29 |
+
"steps": ["grasp the cup", "place it"],
|
| 30 |
+
"final_state": "the cup on the table",
|
| 31 |
+
"edit_prompt": "Remove the robot and add two human hands resting at the bottom edge.",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def selection():
|
| 36 |
+
return {
|
| 37 |
+
"episodes": [{"episode_index": 0, "length": 300}, {"episode_index": 1, "length": 600}],
|
| 38 |
+
"seconds": 30,
|
| 39 |
+
"info": {"fps": 30},
|
| 40 |
+
"dataset": "source/robot",
|
| 41 |
+
"revision": "a" * 40,
|
| 42 |
+
"start": 0,
|
| 43 |
+
"count": 2,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def video_estimate(selection_, segments=0, parallel=8, hours=4):
|
| 48 |
+
return studio.estimate(
|
| 49 |
+
selection_,
|
| 50 |
+
["Subtasks", studio.HUMAN_VIDEOS],
|
| 51 |
+
0.1,
|
| 52 |
+
hours,
|
| 53 |
+
0.0005,
|
| 54 |
+
human_video_segments=segments,
|
| 55 |
+
human_video_parallel=parallel,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def episodes(*seconds, fps=30):
|
| 60 |
+
return {
|
| 61 |
+
**selection(),
|
| 62 |
+
"episodes": [{"episode_index": i, "length": int(s * fps)} for i, s in enumerate(seconds)],
|
| 63 |
+
"seconds": sum(seconds),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_video_estimate_scales_with_episode_length_and_count():
|
| 68 |
+
base = studio.estimate(selection(), ["Subtasks"], 0.1, 4, 0.0005)
|
| 69 |
+
assert base["media"] == 0 and base["videos"] == 0
|
| 70 |
+
# 10 s and 20 s episodes: about one subtask per 5 s -> 2 + 4 videos, up to 4 + 8.
|
| 71 |
+
every = video_estimate(selection())
|
| 72 |
+
assert every["videos"] == 6 and every["videos_max"] == 12
|
| 73 |
+
assert every["media"] == pytest.approx(6 * human_video_segment_cost())
|
| 74 |
+
assert every["media_max"] == pytest.approx(12 * human_video_segment_cost())
|
| 75 |
+
assert every["high"] - base["high"] > every["media_max"]
|
| 76 |
+
assert every["calls"] == base["calls"] + 12
|
| 77 |
+
# Longer episodes and more episodes cost more; very short episodes still get one video.
|
| 78 |
+
assert video_estimate(episodes(40, 40))["media"] > every["media"]
|
| 79 |
+
assert video_estimate(episodes(10, 20, 10, 20))["videos"] == 12
|
| 80 |
+
assert video_estimate(episodes(1, 2))["videos"] == 2
|
| 81 |
+
# A per-episode cap bounds both the expected and maximum count.
|
| 82 |
+
capped = video_estimate(selection(), segments=3)
|
| 83 |
+
assert capped["videos"] == 5 and capped["videos_max"] == 6
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_parallel_generations_shorten_the_video_time_estimate():
|
| 87 |
+
long = episodes(*[60] * 20) # 240 videos expected
|
| 88 |
+
slow, fast = video_estimate(long, parallel=1), video_estimate(long, parallel=32)
|
| 89 |
+
assert slow["video_minutes"] == 240 * 3 and fast["video_minutes"] == 8 * 3
|
| 90 |
+
assert slow["media"] == fast["media"]
|
| 91 |
+
with pytest.raises(ValueError, match="parallel"):
|
| 92 |
+
video_estimate(selection(), parallel=64)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_videos_need_subtasks_and_supported_choices():
|
| 96 |
+
with pytest.raises(ValueError, match="Subtasks"):
|
| 97 |
+
studio.estimate(
|
| 98 |
+
selection(), [studio.HUMAN_VIDEOS], 0.1, 4, 0.0005, human_video_segments=0, human_video_parallel=8
|
| 99 |
+
)
|
| 100 |
+
with pytest.raises(ValueError, match="videos per episode"):
|
| 101 |
+
video_estimate(selection(), segments=4)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def video_plan(profile=None):
|
| 105 |
+
features = ["Subtasks", studio.HUMAN_VIDEOS]
|
| 106 |
+
return {
|
| 107 |
+
"selection": selection(),
|
| 108 |
+
"features": features,
|
| 109 |
+
"camera": CAMERA,
|
| 110 |
+
"vqa_hz": 0.1,
|
| 111 |
+
"mode": studio.COPY,
|
| 112 |
+
"created": time.time(),
|
| 113 |
+
"nonce": "request123",
|
| 114 |
+
"human_video": {
|
| 115 |
+
"segments_per_episode": 2,
|
| 116 |
+
"parallel": 8,
|
| 117 |
+
"profile": profile or human_video_profile(),
|
| 118 |
+
},
|
| 119 |
+
"costs": studio.estimate(
|
| 120 |
+
selection(), features, 0.1, 4, 0.0005, human_video_segments=2, human_video_parallel=8
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def api_mock(fal_status="live"):
|
| 126 |
+
api = Mock()
|
| 127 |
+
api.whoami.return_value = {"name": "alice"}
|
| 128 |
+
api.list_jobs.return_value = []
|
| 129 |
+
api.repo_exists.return_value = False
|
| 130 |
+
api.dataset_info.return_value = SimpleNamespace(id="source/robot", sha="a" * 40)
|
| 131 |
+
api.space_info.return_value = SimpleNamespace(sha="b" * 40)
|
| 132 |
+
api.list_jobs_hardware.return_value = [
|
| 133 |
+
SimpleNamespace(name="cpu-upgrade", unit_label="minute", unit_cost_usd=0.0005)
|
| 134 |
+
]
|
| 135 |
+
api.model_info.return_value = SimpleNamespace(
|
| 136 |
+
inference_provider_mapping={
|
| 137 |
+
studio.PROVIDER: SimpleNamespace(status="live"),
|
| 138 |
+
"fal-ai": SimpleNamespace(status=fal_status),
|
| 139 |
+
}
|
| 140 |
+
)
|
| 141 |
+
api.run_job.return_value = SimpleNamespace(id="job123")
|
| 142 |
+
return api
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def launch(plan, api, budget=20):
|
| 146 |
+
return studio.launch(
|
| 147 |
+
plan,
|
| 148 |
+
"alice/out",
|
| 149 |
+
budget,
|
| 150 |
+
True,
|
| 151 |
+
"token",
|
| 152 |
+
space="lerobot/annotation-studio",
|
| 153 |
+
api_factory=lambda **_: api,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_launch_checks_fal_models_and_pins_video_settings():
|
| 158 |
+
api = api_mock()
|
| 159 |
+
assert launch(video_plan(), api)["id"] == "job123"
|
| 160 |
+
checked = [call.args[0] for call in api.model_info.call_args_list]
|
| 161 |
+
assert {"MiniMaxAI/MiniMax-H3", "Qwen/Qwen-Image-Edit-2509"} <= set(checked)
|
| 162 |
+
manifest = json.loads(api.run_job.call_args.kwargs["env"]["ANNOTATION_MANIFEST"])
|
| 163 |
+
assert manifest["human_video"]["segments_per_episode"] == 2
|
| 164 |
+
assert manifest["human_video"]["profile"]["resolution"] == "480P"
|
| 165 |
+
# The job downloads the human-video prompt files with the other *.txt resources.
|
| 166 |
+
assert "*.txt" in str(api.run_job.call_args.kwargs["command"])
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_launch_refuses_offline_fal_models_or_stale_video_prices():
|
| 170 |
+
api = api_mock(fal_status="staging")
|
| 171 |
+
with pytest.raises(ValueError, match="fal"):
|
| 172 |
+
launch(video_plan(), api)
|
| 173 |
+
stale = {**human_video_profile(), "video_usd": 0.01}
|
| 174 |
+
with pytest.raises(ValueError, match="Video pricing"):
|
| 175 |
+
launch(video_plan(stale), api_mock())
|
| 176 |
+
api.run_job.assert_not_called()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_media_budget_reserves_before_request_and_stops_at_target():
|
| 180 |
+
budget = Budget(0.5, 0.15, 0.5)
|
| 181 |
+
reservation = budget.reserve_media(0.3)
|
| 182 |
+
budget.record_media(reservation)
|
| 183 |
+
assert budget.summary()["media_usd"] == pytest.approx(0.3)
|
| 184 |
+
assert budget.summary()["measured_inference_usd"] == pytest.approx(0.3)
|
| 185 |
+
with pytest.raises(BudgetExceededError):
|
| 186 |
+
budget.reserve_media(0.3)
|
| 187 |
+
assert budget.summary()["media_requests"] == 1
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class FakeMedia:
|
| 191 |
+
concurrency = 4
|
| 192 |
+
|
| 193 |
+
def __init__(self, fail_first=False, budget_after=None):
|
| 194 |
+
self.profile = human_video_profile()
|
| 195 |
+
self.fail_first, self.budget_after = fail_first, budget_after
|
| 196 |
+
self.edits, self.videos = [], []
|
| 197 |
+
|
| 198 |
+
def edit_image(self, image, prompt):
|
| 199 |
+
self.edits.append((image.size, prompt))
|
| 200 |
+
return image.transpose(Image.FLIP_LEFT_RIGHT)
|
| 201 |
+
|
| 202 |
+
def image_to_video(self, image, prompt):
|
| 203 |
+
self.videos.append(prompt)
|
| 204 |
+
if self.fail_first and len(self.videos) == 1:
|
| 205 |
+
raise RuntimeError("provider error")
|
| 206 |
+
if self.budget_after is not None and len(self.videos) > self.budget_after:
|
| 207 |
+
raise BudgetExceededError("Spending target reached.")
|
| 208 |
+
return b"\x00\x00\x00\x18ftypmp42video"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def respond(messages):
|
| 212 |
+
text = " ".join(p.get("text", "") for m in messages for p in m["content"] if isinstance(p, dict))
|
| 213 |
+
if "meaningful action phases" in text:
|
| 214 |
+
return {
|
| 215 |
+
"subtasks": [
|
| 216 |
+
{"text": "grasp the cup", "start": 0, "end": 2.5},
|
| 217 |
+
{"text": "place the cup on table", "start": 2.5, "end": 3.9},
|
| 218 |
+
]
|
| 219 |
+
}
|
| 220 |
+
if "preparing a human demonstration" in text:
|
| 221 |
+
return PLAN
|
| 222 |
+
if "image-to-video model will animate it" in text:
|
| 223 |
+
return {"video_prompt": "From a fixed first-person camera perspective, ...", "caption": "A hand."}
|
| 224 |
+
raise AssertionError("Unexpected prompt")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@pytest.fixture
|
| 228 |
+
def episode(tmp_path):
|
| 229 |
+
import av
|
| 230 |
+
import numpy as np
|
| 231 |
+
|
| 232 |
+
video = tmp_path / "videos" / CAMERA / "chunk-000" / "file-000.mp4"
|
| 233 |
+
video.parent.mkdir(parents=True)
|
| 234 |
+
with av.open(str(video), "w") as container:
|
| 235 |
+
stream = container.add_stream("mpeg4", rate=10)
|
| 236 |
+
stream.width, stream.height, stream.pix_fmt = 320, 240, "yuv420p"
|
| 237 |
+
for i in range(40):
|
| 238 |
+
frame = av.VideoFrame.from_ndarray(np.full((240, 320, 3), i * 5, dtype=np.uint8), format="rgb24")
|
| 239 |
+
for packet in stream.encode(frame):
|
| 240 |
+
container.mux(packet)
|
| 241 |
+
for packet in stream.encode():
|
| 242 |
+
container.mux(packet)
|
| 243 |
+
info = {"fps": 10, "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4"}
|
| 244 |
+
row = {
|
| 245 |
+
"length": 40,
|
| 246 |
+
f"videos/{CAMERA}/chunk_index": 0,
|
| 247 |
+
f"videos/{CAMERA}/file_index": 0,
|
| 248 |
+
f"videos/{CAMERA}/from_timestamp": 0.0,
|
| 249 |
+
}
|
| 250 |
+
record = EpisodeRecord(
|
| 251 |
+
0,
|
| 252 |
+
"move the cup to the table",
|
| 253 |
+
tuple(i / 10 for i in range(40)),
|
| 254 |
+
tuple(range(40)),
|
| 255 |
+
tmp_path / "x",
|
| 256 |
+
0,
|
| 257 |
+
40,
|
| 258 |
+
)
|
| 259 |
+
return record, row, info
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def annotate(tmp_path, episode, media, segments=2, staging="staging"):
|
| 263 |
+
from worker import annotate_episode
|
| 264 |
+
|
| 265 |
+
record, row, info = episode
|
| 266 |
+
manifest = {
|
| 267 |
+
"camera": CAMERA,
|
| 268 |
+
"features": ["Subtasks", studio.HUMAN_VIDEOS],
|
| 269 |
+
"vqa_hz": 0.1,
|
| 270 |
+
"prompts": {},
|
| 271 |
+
"human_video": {"segments_per_episode": segments, "profile": human_video_profile()},
|
| 272 |
+
}
|
| 273 |
+
return annotate_episode(
|
| 274 |
+
record, row, info, tmp_path, manifest, StubVlmClient(respond), tmp_path / staging, media
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_episode_generates_videos_after_annotations_from_full_resolution_frames(tmp_path, episode):
|
| 279 |
+
media = FakeMedia()
|
| 280 |
+
result = annotate(tmp_path, episode, media)
|
| 281 |
+
assert result["status"] == "completed" and result["human_videos"] == {"completed": 2}
|
| 282 |
+
entries = result["human_video_entries"]
|
| 283 |
+
assert [e["subtask"] for e in entries] == ["grasp the cup", "place the cup on table"]
|
| 284 |
+
assert entries[1]["start_timestamp"] == 2.5 and entries[1]["end_timestamp"] == 3.9
|
| 285 |
+
# The edit uses the camera's full resolution, not the 224 px VLM thumbnails.
|
| 286 |
+
assert {size for size, _ in media.edits} == {(320, 240)}
|
| 287 |
+
for entry in entries:
|
| 288 |
+
assert set(entry["files"]) == {"robot_frame", "first_frame", "video"}
|
| 289 |
+
assert Path(entry["files"]["video"]).read_bytes().startswith(b"\x00\x00\x00\x18ftyp")
|
| 290 |
+
# Only language styles are staged for the parquet writer.
|
| 291 |
+
staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
|
| 292 |
+
assert {r.get("style") for rows in staged.values() for r in rows} == {"subtask"}
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def test_failed_video_is_recorded_and_budget_stop_keeps_annotations(tmp_path, episode):
|
| 296 |
+
result = annotate(tmp_path, episode, FakeMedia(fail_first=True))
|
| 297 |
+
assert sorted(e["status"] for e in result["human_video_entries"]) == ["completed", "failed"]
|
| 298 |
+
|
| 299 |
+
stopped = annotate(tmp_path, episode, FakeMedia(budget_after=0), segments=1, staging="second")
|
| 300 |
+
assert stopped["status"] == "completed"
|
| 301 |
+
assert stopped["human_videos"] == {"budget_stopped": 1}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def test_segment_cap_limits_videos_and_zero_means_every_subtask(tmp_path, episode):
|
| 305 |
+
media = FakeMedia()
|
| 306 |
+
result = annotate(tmp_path, episode, media, segments=1)
|
| 307 |
+
assert len(result["human_video_entries"]) == 1 and len(media.videos) == 1
|
| 308 |
+
every = annotate(tmp_path, episode, FakeMedia(), segments=0, staging="every")
|
| 309 |
+
assert len(every["human_video_entries"]) == 2
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def test_parallel_setting_bounds_simultaneous_media_requests():
|
| 313 |
+
import threading
|
| 314 |
+
|
| 315 |
+
budget = Budget(5, 0.15, 0.5)
|
| 316 |
+
assert human_video.FalMedia("t", budget, lambda: 3600).concurrency == human_video_profile()["concurrency"]
|
| 317 |
+
assert human_video.FalMedia("t", budget, lambda: 3600, concurrency=64).concurrency == 32
|
| 318 |
+
media = human_video.FalMedia("t", budget, lambda: 3600, concurrency=3)
|
| 319 |
+
active, peak, lock = [0], [0], threading.Lock()
|
| 320 |
+
|
| 321 |
+
def request():
|
| 322 |
+
with media.semaphore:
|
| 323 |
+
with lock:
|
| 324 |
+
active[0] += 1
|
| 325 |
+
peak[0] = max(peak[0], active[0])
|
| 326 |
+
time.sleep(0.02)
|
| 327 |
+
with lock:
|
| 328 |
+
active[0] -= 1
|
| 329 |
+
|
| 330 |
+
threads = [threading.Thread(target=request) for _ in range(12)]
|
| 331 |
+
for t in threads:
|
| 332 |
+
t.start()
|
| 333 |
+
for t in threads:
|
| 334 |
+
t.join()
|
| 335 |
+
assert peak[0] == 3
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def test_operations_upload_media_and_index(tmp_path):
|
| 339 |
+
from worker import HUMAN_VIDEO_DIR, human_video_operations
|
| 340 |
+
|
| 341 |
+
video = tmp_path / "segment_000.mp4"
|
| 342 |
+
video.write_bytes(b"video")
|
| 343 |
+
entries = [
|
| 344 |
+
{"source_episode_index": 7, "segment_index": 0, "status": "completed", "files": {"video": str(video)}}
|
| 345 |
+
]
|
| 346 |
+
index = []
|
| 347 |
+
operations = human_video_operations(entries, index)
|
| 348 |
+
assert [op.path_in_repo for op in operations] == [
|
| 349 |
+
f"{HUMAN_VIDEO_DIR}/episode_000007/segment_000.mp4",
|
| 350 |
+
f"{HUMAN_VIDEO_DIR}/index.json",
|
| 351 |
+
]
|
| 352 |
+
assert index == [
|
| 353 |
+
{
|
| 354 |
+
"source_episode_index": 7,
|
| 355 |
+
"segment_index": 0,
|
| 356 |
+
"status": "completed",
|
| 357 |
+
"video_path": f"{HUMAN_VIDEO_DIR}/episode_000007/segment_000.mp4",
|
| 358 |
+
}
|
| 359 |
+
]
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class FakeSession:
|
| 363 |
+
def __init__(self, statuses):
|
| 364 |
+
self.statuses, self.calls = list(statuses), []
|
| 365 |
+
|
| 366 |
+
def post(self, url, json=None, headers=None, timeout=None):
|
| 367 |
+
self.calls.append(("post", url, headers))
|
| 368 |
+
return SimpleNamespace(
|
| 369 |
+
status_code=200,
|
| 370 |
+
json=lambda: {
|
| 371 |
+
"request_id": "r1",
|
| 372 |
+
"status": "IN_QUEUE",
|
| 373 |
+
"response_url": "https://queue.fal.run/minimax/h3/requests/r1",
|
| 374 |
+
},
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
def get(self, url, headers=None, timeout=None):
|
| 378 |
+
self.calls.append(("get", url, headers))
|
| 379 |
+
if url.endswith("/status?_subdomain=queue"):
|
| 380 |
+
status = self.statuses.pop(0)
|
| 381 |
+
return SimpleNamespace(status_code=200, json=lambda: {"status": status})
|
| 382 |
+
if url.startswith("https://router.huggingface.co"):
|
| 383 |
+
return SimpleNamespace(
|
| 384 |
+
status_code=200, json=lambda: {"video": {"url": "https://cdn.fal.media/v.mp4"}}
|
| 385 |
+
)
|
| 386 |
+
return SimpleNamespace(status_code=200, content=b"mp4-bytes")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
@pytest.fixture
|
| 390 |
+
def fal(monkeypatch):
|
| 391 |
+
import huggingface_hub.utils
|
| 392 |
+
from huggingface_hub.inference._providers.fal_ai import FalAIImageToVideoTask
|
| 393 |
+
|
| 394 |
+
seen = {}
|
| 395 |
+
|
| 396 |
+
def prepare_request(self, *, inputs, parameters, headers, model, api_key):
|
| 397 |
+
seen.update(task=self.task, parameters=parameters, model=model)
|
| 398 |
+
return SimpleNamespace(
|
| 399 |
+
url="https://router.huggingface.co/fal-ai/minimax/h3/image-to-video?_subdomain=queue",
|
| 400 |
+
json={"prompt": parameters["prompt"]},
|
| 401 |
+
headers={"authorization": f"Bearer {api_key}"},
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
monkeypatch.setattr(FalAIImageToVideoTask, "prepare_request", prepare_request)
|
| 405 |
+
monkeypatch.setattr(huggingface_hub.utils, "hf_raise_for_status", lambda response: None)
|
| 406 |
+
|
| 407 |
+
def use(session):
|
| 408 |
+
monkeypatch.setattr(huggingface_hub.utils, "get_session", lambda: session)
|
| 409 |
+
|
| 410 |
+
return seen, use
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def test_video_polls_router_charges_quoted_price_and_never_sends_token_to_cdn(fal):
|
| 414 |
+
seen, use = fal
|
| 415 |
+
session = FakeSession(["IN_PROGRESS", "COMPLETED"])
|
| 416 |
+
use(session)
|
| 417 |
+
budget = Budget(5, 0.15, 0.5)
|
| 418 |
+
media = human_video.FalMedia("hf_token", budget, lambda: 3600, poll_interval=0)
|
| 419 |
+
assert media.image_to_video(Image.new("RGB", (64, 48)), "a prompt") == b"mp4-bytes"
|
| 420 |
+
assert seen["task"] == "image-text-to-video" and seen["model"] == "MiniMaxAI/MiniMax-H3"
|
| 421 |
+
assert seen["parameters"] == {"prompt": "a prompt", "resolution": "480P", "duration": 5}
|
| 422 |
+
status_url = "https://router.huggingface.co/fal-ai/minimax/h3/requests/r1/status?_subdomain=queue"
|
| 423 |
+
assert [c[1] for c in session.calls if c[0] == "get"][:2] == [status_url, status_url]
|
| 424 |
+
cdn = [c for c in session.calls if c[1].startswith("https://cdn.fal.media")]
|
| 425 |
+
assert cdn and cdn[0][2] is None
|
| 426 |
+
assert budget.summary()["media_usd"] == pytest.approx(human_video_profile()["video_usd"])
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def test_video_is_not_started_without_runtime_for_it(fal):
|
| 430 |
+
_, use = fal
|
| 431 |
+
session = FakeSession([])
|
| 432 |
+
use(session)
|
| 433 |
+
budget = Budget(5, 0.15, 0.5)
|
| 434 |
+
media = human_video.FalMedia("hf_token", budget, lambda: 60, poll_interval=0)
|
| 435 |
+
with pytest.raises(DeadlineExceededError):
|
| 436 |
+
media.image_to_video(Image.new("RGB", (64, 48)), "a prompt")
|
| 437 |
+
assert session.calls == [] and budget.summary()["media_requests"] == 0
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def test_media_endpoint_serves_only_generated_files(monkeypatch):
|
| 441 |
+
from fastapi.testclient import TestClient
|
| 442 |
+
|
| 443 |
+
import server
|
| 444 |
+
|
| 445 |
+
server.SESSIONS.clear()
|
| 446 |
+
client = TestClient(server.app)
|
| 447 |
+
sid_response = client.get("/api/session")
|
| 448 |
+
sid = client.cookies.get(server.COOKIE)
|
| 449 |
+
server.SESSIONS[sid].update(user={"name": "alice"}, token="secret-token")
|
| 450 |
+
assert sid_response.status_code == 200
|
| 451 |
+
reads = []
|
| 452 |
+
monkeypatch.setattr(server, "studio_output", lambda job_id, current: (None, "alice/out"))
|
| 453 |
+
|
| 454 |
+
def read(output, path, token):
|
| 455 |
+
reads.append((output, path, token))
|
| 456 |
+
if path.endswith("index.json"):
|
| 457 |
+
return json.dumps(
|
| 458 |
+
[
|
| 459 |
+
{
|
| 460 |
+
"source_episode_index": 3,
|
| 461 |
+
"episode_index": 0,
|
| 462 |
+
"segment_index": 0,
|
| 463 |
+
"status": "completed",
|
| 464 |
+
"subtask": "grasp",
|
| 465 |
+
"video_path": "annotation_studio/human_videos/episode_000003/segment_000.mp4",
|
| 466 |
+
"edit_prompt": "not exposed in the preview",
|
| 467 |
+
}
|
| 468 |
+
]
|
| 469 |
+
).encode()
|
| 470 |
+
return b"mp4"
|
| 471 |
+
|
| 472 |
+
monkeypatch.setattr(server, "read_output_file", read)
|
| 473 |
+
listed = client.get("/api/jobs/job1/human-videos").json()
|
| 474 |
+
assert listed[0]["video"] == (
|
| 475 |
+
"/api/jobs/job1/media?path=annotation_studio/human_videos/episode_000003/segment_000.mp4"
|
| 476 |
+
)
|
| 477 |
+
assert "edit_prompt" not in listed[0]
|
| 478 |
+
media = client.get(listed[0]["video"])
|
| 479 |
+
assert media.status_code == 200 and media.content == b"mp4"
|
| 480 |
+
assert media.headers["content-type"] == "video/mp4"
|
| 481 |
+
for bad in ("annotation_studio/run.json", "data/chunk-000/file-000.parquet", "../x.mp4"):
|
| 482 |
+
assert client.get(f"/api/jobs/job1/media?path={bad}").status_code == 400
|
| 483 |
+
assert "secret-token" not in json.dumps(listed)
|
| 484 |
+
anonymous = TestClient(server.app).get("/api/jobs/job1/media?path=" + listed[0]["video"].split("=")[1])
|
| 485 |
+
assert anonymous.status_code == 401
|
test_studio.py
CHANGED
|
@@ -111,7 +111,7 @@ def test_real_video_pipeline_and_checkbox_filtering(tmp_path, features, custom):
|
|
| 111 |
)
|
| 112 |
staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
|
| 113 |
actual_styles = {r.get("style") for rows in staged.values() for r in rows}
|
| 114 |
-
expected = {studio.FEATURES[f] for f in features}
|
| 115 |
if "Interjections & speech" in features:
|
| 116 |
expected.add(None)
|
| 117 |
assert actual_styles == expected
|
|
|
|
| 111 |
)
|
| 112 |
staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
|
| 113 |
actual_styles = {r.get("style") for rows in staged.values() for r in rows}
|
| 114 |
+
expected = {studio.FEATURES[f] for f in features if f != studio.HUMAN_VIDEOS}
|
| 115 |
if "Interjections & speech" in features:
|
| 116 |
expected.add(None)
|
| 117 |
assert actual_styles == expected
|
test_subset.py
CHANGED
|
@@ -268,7 +268,7 @@ def test_worker_publishes_only_successful_subset_without_hub_duplication(
|
|
| 268 |
monkeypatch.setattr(worker, "hf_hub_download", download)
|
| 269 |
monkeypatch.setattr(worker, "Provider", lambda *args, **kw: SimpleNamespace(concurrency=2))
|
| 270 |
|
| 271 |
-
def annotate(record, row, info, root, manifest, provider, staging):
|
| 272 |
if record.episode_index == 1:
|
| 273 |
raise ValueError("Simulated annotation validation failure")
|
| 274 |
EpisodeStaging(staging, record.episode_index).write(
|
|
|
|
| 268 |
monkeypatch.setattr(worker, "hf_hub_download", download)
|
| 269 |
monkeypatch.setattr(worker, "Provider", lambda *args, **kw: SimpleNamespace(concurrency=2))
|
| 270 |
|
| 271 |
+
def annotate(record, row, info, root, manifest, provider, staging, media=None):
|
| 272 |
if record.episode_index == 1:
|
| 273 |
raise ValueError("Simulated annotation validation failure")
|
| 274 |
EpisodeStaging(staging, record.episode_index).write(
|
worker.py
CHANGED
|
@@ -15,9 +15,10 @@ from pathlib import Path
|
|
| 15 |
import av
|
| 16 |
import pyarrow.parquet as pq
|
| 17 |
from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
|
|
|
|
| 18 |
from prompt_config import validate_prompts
|
| 19 |
from provider import Budget, BudgetExceededError, DeadlineExceededError, Provider
|
| 20 |
-
from studio import COPY, FEATURES, read_selection, relative_path
|
| 21 |
from subset import EpisodeSubset
|
| 22 |
|
| 23 |
from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
|
|
@@ -127,9 +128,11 @@ class TunedPlan(plan_module.PlanSubtasksMemoryModule):
|
|
| 127 |
class SourceFrames:
|
| 128 |
"""Exact timestamp seeks; a missing image fails the episode instead of generating from text."""
|
| 129 |
|
| 130 |
-
def __init__(self, row, info, root, camera):
|
| 131 |
self.row, self.info, self.root = row, info, root
|
| 132 |
self.camera_key, self.camera_keys, self.cache = camera, [camera], {}
|
|
|
|
|
|
|
| 133 |
|
| 134 |
def frames_at(self, record, timestamps, camera_key=None):
|
| 135 |
camera = camera_key or self.camera_key
|
|
@@ -159,7 +162,8 @@ class SourceFrames:
|
|
| 159 |
if closest is None or distance > 1 / fps + 1e-4:
|
| 160 |
raise RuntimeError("Source video is missing an expected frame.")
|
| 161 |
image = closest.to_image()
|
| 162 |
-
|
|
|
|
| 163 |
self.cache[idx] = image
|
| 164 |
return [self.cache[i] for i in indices]
|
| 165 |
|
|
@@ -197,21 +201,29 @@ class PreservingWriter(LanguageColumnsWriter):
|
|
| 197 |
|
| 198 |
|
| 199 |
def requested_styles(features):
|
| 200 |
-
|
|
|
|
| 201 |
if "Interjections & speech" in features:
|
| 202 |
styles.add(None)
|
| 203 |
return styles
|
| 204 |
|
| 205 |
|
| 206 |
-
def annotate_episode(record, row, info, root, manifest, provider, staging_dir):
|
| 207 |
context = CUSTOM_PROMPTS.set(validate_prompts(manifest.get("prompts", {}), manifest["features"]))
|
| 208 |
try:
|
| 209 |
-
return _annotate_episode(record, row, info, root, manifest, provider, staging_dir)
|
| 210 |
finally:
|
| 211 |
CUSTOM_PROMPTS.reset(context)
|
| 212 |
|
| 213 |
|
| 214 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
frames = SourceFrames(row, info, root, manifest["camera"])
|
| 216 |
features = manifest["features"]
|
| 217 |
staging = EpisodeStaging(staging_dir, record.episode_index)
|
|
@@ -252,15 +264,62 @@ def _annotate_episode(record, row, info, root, manifest, provider, staging_dir):
|
|
| 252 |
report = StagingValidator(dataset_camera_keys=(manifest["camera"],)).validate([record], staging_dir)
|
| 253 |
if not report.ok:
|
| 254 |
raise ValueError("Generated annotations did not pass pipeline validation.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
styles = requested_styles(features)
|
| 256 |
for module_name, rows in staging.read_all().items():
|
| 257 |
staging.write(module_name, [r for r in rows if r.get("style") in styles])
|
| 258 |
-
|
| 259 |
"episode_index": record.episode_index,
|
| 260 |
"status": "completed",
|
| 261 |
"recovery_used": getattr(plan, "used_fallback", False),
|
| 262 |
"rows": sum(len(rows) for rows in staging.read_all().values()),
|
| 263 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
def create_output_repo(api, manifest):
|
|
@@ -286,6 +345,21 @@ def create_output_repo(api, manifest):
|
|
| 286 |
f"at `{manifest['dataset_revision']}`. {description} "
|
| 287 |
"See `annotation_studio/run.json` for completeness.\n"
|
| 288 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
api.upload_file(
|
| 290 |
path_or_fileobj=card.encode(), path_in_repo="README.md", repo_id=output, repo_type="dataset"
|
| 291 |
)
|
|
@@ -344,6 +418,18 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 344 |
concurrency=manifest.get("request_concurrency", 2),
|
| 345 |
generation_options=manifest.get("generation_options"),
|
| 346 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
progress = {
|
| 348 |
"manifest": manifest,
|
| 349 |
"pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
|
|
@@ -435,6 +521,7 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 435 |
manifest,
|
| 436 |
provider,
|
| 437 |
staging,
|
|
|
|
| 438 |
): record
|
| 439 |
for record in records
|
| 440 |
}
|
|
@@ -461,8 +548,15 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 461 |
"status": "failed",
|
| 462 |
"error_type": type(exc).__name__,
|
| 463 |
}
|
|
|
|
| 464 |
progress["episodes"].append(result)
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
print(f"Saved episode {record.episode_index}: {result['status']}", flush=True)
|
| 467 |
if completed:
|
| 468 |
writer = PreservingWriter(
|
|
@@ -498,6 +592,11 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 498 |
local_dir=root,
|
| 499 |
)
|
| 500 |
new_id, written = subset.add(record.episode_index, annotated_table)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
with commit_lock:
|
| 502 |
previous_ids = list(progress["written_episode_ids"])
|
| 503 |
previous_map = list(progress["source_episode_map"])
|
|
@@ -511,6 +610,7 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 511 |
)
|
| 512 |
for p in [*written, *attribution]
|
| 513 |
]
|
|
|
|
| 514 |
)
|
| 515 |
except Exception:
|
| 516 |
progress["written_episode_ids"] = previous_ids
|
|
|
|
| 15 |
import av
|
| 16 |
import pyarrow.parquet as pq
|
| 17 |
from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
|
| 18 |
+
from human_video import FalMedia, generate_human_videos
|
| 19 |
from prompt_config import validate_prompts
|
| 20 |
from provider import Budget, BudgetExceededError, DeadlineExceededError, Provider
|
| 21 |
+
from studio import COPY, FEATURES, HUMAN_VIDEOS, read_selection, relative_path
|
| 22 |
from subset import EpisodeSubset
|
| 23 |
|
| 24 |
from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
|
|
|
|
| 128 |
class SourceFrames:
|
| 129 |
"""Exact timestamp seeks; a missing image fails the episode instead of generating from text."""
|
| 130 |
|
| 131 |
+
def __init__(self, row, info, root, camera, max_size=(224, 224)):
|
| 132 |
self.row, self.info, self.root = row, info, root
|
| 133 |
self.camera_key, self.camera_keys, self.cache = camera, [camera], {}
|
| 134 |
+
# None keeps full resolution (the first frame of a human demo video).
|
| 135 |
+
self.max_size = max_size
|
| 136 |
|
| 137 |
def frames_at(self, record, timestamps, camera_key=None):
|
| 138 |
camera = camera_key or self.camera_key
|
|
|
|
| 162 |
if closest is None or distance > 1 / fps + 1e-4:
|
| 163 |
raise RuntimeError("Source video is missing an expected frame.")
|
| 164 |
image = closest.to_image()
|
| 165 |
+
if self.max_size:
|
| 166 |
+
image.thumbnail(self.max_size)
|
| 167 |
self.cache[idx] = image
|
| 168 |
return [self.cache[i] for i in indices]
|
| 169 |
|
|
|
|
| 201 |
|
| 202 |
|
| 203 |
def requested_styles(features):
|
| 204 |
+
# Human demo videos are media files, not language rows.
|
| 205 |
+
styles = {FEATURES[f] for f in features if f != HUMAN_VIDEOS}
|
| 206 |
if "Interjections & speech" in features:
|
| 207 |
styles.add(None)
|
| 208 |
return styles
|
| 209 |
|
| 210 |
|
| 211 |
+
def annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
|
| 212 |
context = CUSTOM_PROMPTS.set(validate_prompts(manifest.get("prompts", {}), manifest["features"]))
|
| 213 |
try:
|
| 214 |
+
return _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media)
|
| 215 |
finally:
|
| 216 |
CUSTOM_PROMPTS.reset(context)
|
| 217 |
|
| 218 |
|
| 219 |
+
def human_video_summary(entries):
|
| 220 |
+
counts = defaultdict(int)
|
| 221 |
+
for entry in entries:
|
| 222 |
+
counts[entry["status"]] += 1
|
| 223 |
+
return dict(counts)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
|
| 227 |
frames = SourceFrames(row, info, root, manifest["camera"])
|
| 228 |
features = manifest["features"]
|
| 229 |
staging = EpisodeStaging(staging_dir, record.episode_index)
|
|
|
|
| 264 |
report = StagingValidator(dataset_camera_keys=(manifest["camera"],)).validate([record], staging_dir)
|
| 265 |
if not report.ok:
|
| 266 |
raise ValueError("Generated annotations did not pass pipeline validation.")
|
| 267 |
+
videos = None
|
| 268 |
+
if HUMAN_VIDEOS in features and media is not None:
|
| 269 |
+
# After validation, so no video is paid for on an episode whose annotations fail.
|
| 270 |
+
full_frames = SourceFrames(row, info, root, manifest["camera"], max_size=None)
|
| 271 |
+
try:
|
| 272 |
+
videos = generate_human_videos(
|
| 273 |
+
record,
|
| 274 |
+
staging,
|
| 275 |
+
provider,
|
| 276 |
+
frames,
|
| 277 |
+
full_frames,
|
| 278 |
+
media,
|
| 279 |
+
staging_dir.parent / "human_videos" / f"episode_{record.episode_index:06d}",
|
| 280 |
+
manifest["human_video"]["segments_per_episode"],
|
| 281 |
+
)
|
| 282 |
+
except BudgetExceededError as exc:
|
| 283 |
+
# The annotations are complete; only the videos stop.
|
| 284 |
+
status = "time_stopped" if isinstance(exc, DeadlineExceededError) else "budget_stopped"
|
| 285 |
+
videos = [{"source_episode_index": record.episode_index, "status": status, "files": {}}]
|
| 286 |
styles = requested_styles(features)
|
| 287 |
for module_name, rows in staging.read_all().items():
|
| 288 |
staging.write(module_name, [r for r in rows if r.get("style") in styles])
|
| 289 |
+
result = {
|
| 290 |
"episode_index": record.episode_index,
|
| 291 |
"status": "completed",
|
| 292 |
"recovery_used": getattr(plan, "used_fallback", False),
|
| 293 |
"rows": sum(len(rows) for rows in staging.read_all().values()),
|
| 294 |
}
|
| 295 |
+
if videos is not None:
|
| 296 |
+
result["human_videos"] = human_video_summary(videos)
|
| 297 |
+
result["human_video_entries"] = videos
|
| 298 |
+
return result
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
HUMAN_VIDEO_DIR = "annotation_studio/human_videos"
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def human_video_operations(entries, index):
|
| 305 |
+
"""Upload an episode's media and the updated index (source episode IDs until materialized)."""
|
| 306 |
+
operations = []
|
| 307 |
+
for entry in entries:
|
| 308 |
+
files = entry.pop("files", {})
|
| 309 |
+
folder = f"{HUMAN_VIDEO_DIR}/episode_{entry['source_episode_index']:06d}"
|
| 310 |
+
for kind, local in files.items():
|
| 311 |
+
name = f"{folder}/{Path(local).name}"
|
| 312 |
+
operations.append(CommitOperationAdd(path_in_repo=name, path_or_fileobj=local))
|
| 313 |
+
entry[f"{kind}_path"] = name
|
| 314 |
+
index.append(entry)
|
| 315 |
+
operations.append(human_video_index_operation(index))
|
| 316 |
+
return operations
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def human_video_index_operation(index):
|
| 320 |
+
return CommitOperationAdd(
|
| 321 |
+
path_in_repo=f"{HUMAN_VIDEO_DIR}/index.json", path_or_fileobj=json.dumps(index, indent=2).encode()
|
| 322 |
+
)
|
| 323 |
|
| 324 |
|
| 325 |
def create_output_repo(api, manifest):
|
|
|
|
| 345 |
f"at `{manifest['dataset_revision']}`. {description} "
|
| 346 |
"See `annotation_studio/run.json` for completeness.\n"
|
| 347 |
)
|
| 348 |
+
if HUMAN_VIDEOS in manifest.get("features", ()):
|
| 349 |
+
profile = manifest["human_video"]["profile"]
|
| 350 |
+
card += (
|
| 351 |
+
"\n## Human demonstration videos\n\n"
|
| 352 |
+
"`annotation_studio/human_videos/` holds a generated first-person human video for "
|
| 353 |
+
+ (
|
| 354 |
+
f"up to {manifest['human_video']['segments_per_episode']} subtasks"
|
| 355 |
+
if manifest["human_video"]["segments_per_episode"]
|
| 356 |
+
else "every subtask"
|
| 357 |
+
)
|
| 358 |
+
+ " per episode: the robot frame, "
|
| 359 |
+
+ f"the {profile['edit_model']} edit and a {profile['resolution']} {profile['video_model']} "
|
| 360 |
+
"video. `index.json` lists the subtask, timestamps, prompts and status of every segment. "
|
| 361 |
+
"These videos are synthetic.\n"
|
| 362 |
+
)
|
| 363 |
api.upload_file(
|
| 364 |
path_or_fileobj=card.encode(), path_in_repo="README.md", repo_id=output, repo_type="dataset"
|
| 365 |
)
|
|
|
|
| 418 |
concurrency=manifest.get("request_concurrency", 2),
|
| 419 |
generation_options=manifest.get("generation_options"),
|
| 420 |
)
|
| 421 |
+
media = (
|
| 422 |
+
FalMedia(
|
| 423 |
+
token,
|
| 424 |
+
budget,
|
| 425 |
+
provider.remaining_time,
|
| 426 |
+
manifest["human_video"]["profile"],
|
| 427 |
+
concurrency=manifest["human_video"].get("parallel"),
|
| 428 |
+
)
|
| 429 |
+
if HUMAN_VIDEOS in manifest["features"] and manifest.get("human_video")
|
| 430 |
+
else None
|
| 431 |
+
)
|
| 432 |
+
human_index = []
|
| 433 |
progress = {
|
| 434 |
"manifest": manifest,
|
| 435 |
"pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
|
|
|
|
| 521 |
manifest,
|
| 522 |
provider,
|
| 523 |
staging,
|
| 524 |
+
media,
|
| 525 |
): record
|
| 526 |
for record in records
|
| 527 |
}
|
|
|
|
| 548 |
"status": "failed",
|
| 549 |
"error_type": type(exc).__name__,
|
| 550 |
}
|
| 551 |
+
entries = result.pop("human_video_entries", None)
|
| 552 |
progress["episodes"].append(result)
|
| 553 |
+
if entries:
|
| 554 |
+
local_files = [f for e in entries for f in e.get("files", {}).values()]
|
| 555 |
+
checkpoint(human_video_operations(entries, human_index))
|
| 556 |
+
for local in local_files:
|
| 557 |
+
Path(local).unlink(missing_ok=True)
|
| 558 |
+
else:
|
| 559 |
+
checkpoint()
|
| 560 |
print(f"Saved episode {record.episode_index}: {result['status']}", flush=True)
|
| 561 |
if completed:
|
| 562 |
writer = PreservingWriter(
|
|
|
|
| 592 |
local_dir=root,
|
| 593 |
)
|
| 594 |
new_id, written = subset.add(record.episode_index, annotated_table)
|
| 595 |
+
extra_operations = []
|
| 596 |
+
for entry in human_index:
|
| 597 |
+
if entry["source_episode_index"] == record.episode_index:
|
| 598 |
+
entry["episode_index"] = new_id
|
| 599 |
+
extra_operations = [human_video_index_operation(human_index)]
|
| 600 |
with commit_lock:
|
| 601 |
previous_ids = list(progress["written_episode_ids"])
|
| 602 |
previous_map = list(progress["source_episode_map"])
|
|
|
|
| 610 |
)
|
| 611 |
for p in [*written, *attribution]
|
| 612 |
]
|
| 613 |
+
+ extra_operations
|
| 614 |
)
|
| 615 |
except Exception:
|
| 616 |
progress["written_episode_ids"] = previous_ids
|