Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ MESH_VIDEO_MODEL="google/veo-3"
MESH_VIDEO_ENDPOINT="/video/generations"
# Leave true for local development. Production startup requires false.
USE_AI_STUBS="true"
ADAPTIVE_EDIT_PLANNING="false"
ADAPTIVE_EDIT_PLANNING="true"
GLOBAL_EDIT_PLANNING="false"
# Emergency-only: keep false so continuity hard-fails cannot be accepted.
ALLOW_HARD_FAILED_ACCEPTANCE="false"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ Important environment groups:
- auth: `AUTH_JWT_SECRET`, `AUTH_JWT_EXPIRES_MINUTES`
- media storage: `S3_BUCKET` and standard AWS settings, or local media fallback
- AI mode: `USE_AI_STUBS`
- local edit rollout: `ADAPTIVE_EDIT_PLANNING` (defaults off; fixed-window fallback stays available)
- local edit windows: `ADAPTIVE_EDIT_PLANNING` (defaults on; fixed-window fallback stays available)
- emergency continuity override: `ALLOW_HARD_FAILED_ACCEPTANCE` (keep off during normal rollout)
- Mesh API gateway: `MESH_API_KEY`, `MESH_API_BASE_URL`, `MESH_MODEL`, `MESH_VIDEO_MODEL`, `MESH_VIDEO_ENDPOINT`
- generation provider: `VIDEO_GEN_PROVIDER`, `VEO_MODEL`, `VIDEO_GENERATION_TIMEOUT`
Expand Down
27 changes: 26 additions & 1 deletion apps/api/app/ai/prompts/edit_plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ INPUT
- entity_description: optional identified subject
- reference image: actual frame at the selected time

Return exactly one grounded variant:
Return one semantic decision and exactly one grounded variant:

{
"decision": {
"scope": "local" | "all_occurrences",
"change_type": "appearance" | "removal" | "replacement" | "motion" | "scene",
"duration_policy": "bounded_action" | "continuous_occurrence" | "all_occurrences",
"target_description": "selected object or feature",
"action_phases": ["ordered phase names for motion; empty for a state change"],
"estimated_action_seconds": 3.5,
"requires_recovery_motion": true,
"preservation_requirements": ["specific things that must remain unchanged"],
"reasoning": "short explanation of temporal meaning"
},
"variants": [
{
"intent": "remove" | "replace" | "transform" | "add" | "restyle",
Expand All @@ -25,6 +36,20 @@ Return exactly one grounded variant:

RULES
- Inspect the reference image. Ground every noun and visual attribute in it.
- Decide temporal meaning before any source clip is cut.
- A state change such as "make this ball pink", "remove this logo", or "give
this cat human-like eyes" normally means the change persists for this selected
target's complete continuous appearance. Use scope "local" and duration_policy
"continuous_occurrence". Do not limit such changes to an arbitrary few seconds.
- An action such as bounce, jump, wave, or turn is local and time-bounded. Use
duration_policy "bounded_action" and estimate enough time for preparation,
action, landing/recovery, and smooth return to source motion.
- Use scope "all_occurrences" and duration_policy "all_occurrences" only when
the user clearly asks for every appearance, everywhere, throughout, or the
whole video. Never interpret an ordinary state change as permission to search
separated appearances across the full video.
- estimated_action_seconds describes action core only. For state changes it is
only a safe fallback; tracking determines the continuous occurrence boundary.
- Preserve the source video's timing, motion, framing, camera, lighting,
background, and every unselected subject unless the user explicitly changes them.
- State the requested result positively and precisely. Never weaken or reinterpret it.
Expand Down
28 changes: 16 additions & 12 deletions apps/api/app/ai/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _resolve_use_ai_stubs() -> bool:


_MOTION_EDIT_RE = re.compile(
r"\b(jump|jumping|run|running|walk|walking|dance|dancing|wave|waving|"
r"\b(jump|jumping|bounce|bouncing|run|running|walk|walking|dance|dancing|wave|waving|"
r"turn|turning|spin|spinning|sit|sitting|stand|standing|kick|throw|"
r"catch|clap|clapping|nod|bow|fall|falls|leap|leaping)\b"
)
Expand Down Expand Up @@ -130,20 +130,30 @@ def _rewrite_motion_prompt(prompt_text: str) -> tuple[bool, bool, str]:
# ------------------------ gemini adapter ------------------------

# simple in-memory plan cache: keyed on (prompt, bbox_string), max 32 entries
_plan_cache: dict[str, list[dict]] = {}
_plan_cache: dict[str, dict] = {}
_plan_cache_order: list[str] = []
_PLAN_CACHE_MAX = 32

async def _plan_variants(prompt: str, bbox: dict, frame_path: str) -> list[dict]:
# only cache text-only plans (frame_path makes every call unique anyway)
cache_key = f"{prompt}|{json.dumps(bbox, sort_keys=True)}"
async def _interpret_edit(prompt: str, bbox: dict, frame_path: str) -> dict:
# Include visual source identity. Same words and bbox coordinates can
# describe completely different targets in different frames.
cache_key = f"{prompt}|{json.dumps(bbox, sort_keys=True)}|{frame_path}"
cached = _plan_cache.get(cache_key)
if cached is not None:
return cached

plan = await _gemini_real.create_edit_plan(
prompt, bbox, frame_path=frame_path
)
_plan_cache[cache_key] = plan
_plan_cache_order.append(cache_key)
if len(_plan_cache_order) > _PLAN_CACHE_MAX:
oldest = _plan_cache_order.pop(0)
_plan_cache.pop(oldest, None)
return plan

async def _plan_variants(prompt: str, bbox: dict, frame_path: str) -> list[dict]:
plan = await _interpret_edit(prompt, bbox, frame_path)
variants = plan.get("variants") if isinstance(plan, dict) else None
if not variants:
return []
Expand All @@ -163,13 +173,6 @@ async def _plan_variants(prompt: str, bbox: dict, frame_path: str) -> list[dict]
# because veo has no visual context for the scene
v["conditioning_strategy"] = "first_frame"

# cache the result for repeated prompts (propagation reuses same prompt)
_plan_cache[cache_key] = variants
_plan_cache_order.append(cache_key)
if len(_plan_cache_order) > _PLAN_CACHE_MAX:
oldest = _plan_cache_order.pop(0)
_plan_cache.pop(oldest, None)

return variants

async def _score_variant(
Expand Down Expand Up @@ -221,6 +224,7 @@ async def _find_entity_in_keyframes(entity: dict, keyframes: list[str]) -> list[
return out

gemini = _types.SimpleNamespace(
interpret_edit=_interpret_edit,
plan_variants=_plan_variants,
score_variant=_score_variant,
identify_entity=_identify_entity,
Expand Down
56 changes: 40 additions & 16 deletions apps/api/app/ai/services/_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,50 @@

# ------------------------------ gemini ------------------------------

async def _gemini_interpret_edit(
prompt: str,
bbox: BBoxDict,
frame_path: str,
) -> dict:
from app.services.edit_scope import plan_prompt_intent

intent = plan_prompt_intent(prompt).intent
region = f"region at ({bbox['x']:.2f},{bbox['y']:.2f}) {bbox['w']:.2f}x{bbox['h']:.2f}"
return {
"decision": {
"scope": intent.scope,
"change_type": intent.change_type,
"duration_policy": intent.duration_policy,
"target_description": intent.target_description or region,
"action_phases": intent.action_phases,
"estimated_action_seconds": intent.estimated_action_seconds,
"requires_recovery_motion": intent.requires_recovery_motion,
"preservation_requirements": intent.preservation_requirements,
"reasoning": "deterministic test interpretation",
},
"variants": [
{
"intent": intent.change_type,
"conditioning_strategy": "first_frame",
"description": f"Apply requested edit to {region}",
"tone": "original visual tone unless explicitly changed",
"color_grading": "original grade unless explicitly changed",
"region_emphasis": region,
"prompt_for_video_edit": (
f"Edit only the selected target in {region}. {prompt}. "
"Preserve all unselected content. Do not regenerate the scene."
),
}
],
}

async def _gemini_plan_variants(
prompt: str,
bbox: BBoxDict,
frame_path: str,
) -> list[EditPlan]:
tones = [
("cinematic warm", "warm shadows, amber highlights, subtle vignette"),
("moody cool", "desaturated blues, crushed blacks, cyan highlights"),
("high contrast", "punchy contrast, saturated mids, clean whites"),
]
region = f"region at ({bbox['x']:.2f},{bbox['y']:.2f}) {bbox['w']:.2f}x{bbox['h']:.2f}"
return [
EditPlan(
description=f"{tone} take: {prompt}",
tone=tone,
color_grading=grade,
region_emphasis=region,
prompt_for_runway=f"{prompt}. Focus changes on {region}. Apply {tone} look with {grade}.",
)
for tone, grade in tones
]
interpreted = await _gemini_interpret_edit(prompt, bbox, frame_path)
return interpreted["variants"]


async def _gemini_score_variant(
Expand Down Expand Up @@ -91,6 +114,7 @@ async def _gemini_find_entity_in_keyframes(


gemini = types.SimpleNamespace(
interpret_edit=_gemini_interpret_edit,
plan_variants=_gemini_plan_variants,
score_variant=_gemini_score_variant,
identify_entity=_gemini_identify_entity,
Expand Down
23 changes: 23 additions & 0 deletions apps/api/app/ai/services/sam.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,29 @@ async def is_available() -> bool:
return False


async def video_tracking_available() -> bool:
"""Check for the full worker used by SAM2 video tracking.

The lightweight Hugging Face Space only exposes still-image segmentation.
Treating it as a video worker adds a slow, guaranteed-failing request to
every edit.
"""
try:
worker_url = get_settings().vision_worker_url.strip()
if not worker_url or _is_huggingface_space(worker_url):
return False
health_url = (
worker_url.replace("segment", "health")
if "modal.run" in worker_url
else f"{worker_url.rstrip('/')}/health"
)
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(health_url)
return response.status_code == 200
except Exception:
return False


async def track_frames(
frame_paths: list[str],
*,
Expand Down
Loading
Loading