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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ agent/active_project.json

# Generated AI tool configs (run: python setup.py)
.claude/commands/fk-*
.agents/skills/fk-*
AGENTS.md
.fk-setup.json

Expand All @@ -28,3 +29,7 @@ extension/_metadata/
.agent/
flow_session_project.json
.venv/
*.mp3
scratch/

series_manifest.json
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,14 @@ This project has reusable skills in `skills/`. When the user says `/fk-<name>`,

| Skill | Purpose |
|-------|---------|
| `/fk-action-choreography` | Action Choreography — AI Cinematic Combat & Motion Direction |
| `/fk-add-material` | fk-add-material — Image Material System |
| `/fk-brand-logo` | fk-brand-logo — Apply Channel Branding (Intro + Outro + Logo + 4K Badge) |
| `/fk-camera-guide` | Camera Guide — Cinematic Video Prompts (Veo 3) |
| `/fk-change-model` | fk-change-model — View & Change Video/Image Model Keys |
| `/fk-change-provider` | fk-change-provider — View & Switch the Reviewer for a Role |
| `/fk-character-bible` | Character Bible & Visual Consistency — AI Film Production |
| `/fk-cinematic-transitions` | Cinematic Transitions & Chapter Bumpers — AI Film Direction |
| `/fk-concat-fit-narrator` | Trim each scene video to fit its TTS narrator duration, burn text overlays, then concatenate into a final video. |
| `/fk-concat` | Download and concatenate all scene videos into a single video with optional TTS narration. |
| `/fk-create-project` | Create a new Google Flow video project. Ask the user for: |
Expand All @@ -110,6 +113,8 @@ This project has reusable skills in `skills/`. When the user says `/fk-<name>`,
| `/fk-research` | fk-research — Fact-Check & Research Before Scripting |
| `/fk-review-board` | Start the Scene Review Board web app for visual feedback on scene chains. |
| `/fk-review-video` | Review AI-generated scene videos for quality. |
| `/fk-scriptwriter` | The Ultimate AI Screenplay Bible — Google Flow & Veo 3 |
| `/fk-sound-design` | Sound Design & Audio Mastering — AI Cinematic Soundscapes |
| `/fk-status` | Show full status dashboard for a project. |
| `/fk-switch-project` | fk-switch-project — Switch Active Project |
| `/fk-thumbnail-guide` | YouTube Thumbnail Guide — Hook-Worthy Design Rules |
Expand Down
106 changes: 104 additions & 2 deletions agent/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,88 @@ async def get_characters(pid: str):
return await repo.get_project_characters(pid)


async def _build_or_update_series_manifest(repo, project, slug: str, output_dir: Path) -> dict:
"""Build or synchronize the project-level series_manifest.json."""
pid = getattr(project, "id", None) or (project.get("id") if isinstance(project, dict) else "")
project_name = getattr(project, "name", None) or (project.get("name") if isinstance(project, dict) else "")
manifest_path = output_dir / "series_manifest.json"

existing = {}
if manifest_path.exists():
try:
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
existing = {}

chars = await repo.get_project_characters(pid)
chars_list = []
locs_list = []
props_list = []

for c in chars:
entity_type = getattr(c, "entity_type", None) or "character"
entry = {
"name": getattr(c, "name", ""),
"slug": getattr(c, "slug", ""),
"entity_type": entity_type,
"description": getattr(c, "description", None),
"image_prompt": getattr(c, "image_prompt", None),
"voice_description": getattr(c, "voice_description", None),
"media_id": getattr(c, "media_id", None),
"reference_image_url": getattr(c, "reference_image_url", None),
}
if entity_type == "character":
chars_list.append(entry)
elif entity_type == "location":
locs_list.append(entry)
else:
props_list.append(entry)

videos = await repo.list_videos(pid)
existing_eps_by_vid = {ep.get("video_id"): ep for ep in existing.get("episodes", []) if "video_id" in ep}
episodes = []
for idx, v in enumerate(videos, 1):
scenes = await repo.list_scenes(v.id)
old_ep = existing_eps_by_vid.get(v.id, {})
episodes.append({
"episode": getattr(v, "display_order", idx) or idx,
"title": getattr(v, "title", None) or old_ep.get("title") or f"Episode {idx}",
"subtitle": getattr(v, "description", "") or old_ep.get("subtitle", ""),
"video_id": v.id,
"scene_count": len(scenes) if scenes else 0,
"status": getattr(v, "status", "IN_PROGRESS"),
})

first_vid = videos[0] if videos else None
orientation = (getattr(first_vid, "orientation", None) if first_vid else None) or existing.get("orientation", "HORIZONTAL")
material_val = getattr(project, "material", None) or (project.get("material") if isinstance(project, dict) else "") or existing.get("material", "realistic")
story_val = getattr(project, "story", None) or (project.get("story") if isinstance(project, dict) else "") or existing.get("story_bible", "")

manifest = {
"series_title": existing.get("series_title") or project_name,
"slug": slug,
"genre": existing.get("genre", "Cinematic / Realistic"),
"material": material_val,
"orientation": orientation,
"story_bible": story_val,
"default_voice": existing.get("default_voice", "vi-VN-NamMinhNeural"),
"origin_project_id": pid,
"shared_entities": {
"characters": chars_list,
"locations": locs_list,
"key_props": props_list,
},
"episodes": episodes,
"updated_at": datetime.now(timezone.utc).isoformat(),
}

manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
return manifest


@router.get("/{pid}/output-dir")
async def get_output_dir(pid: str):
"""Get or create project output directory with meta.json."""
"""Get or create project output directory with meta.json and series_manifest.json."""
repo = _get_repo()
project = await repo.get_project(pid)
if not project:
Expand Down Expand Up @@ -341,7 +420,30 @@ async def get_output_dir(pid: str):
meta["created_at"] = existing.get("created_at", now)
meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")

return {"slug": slug, "path": f"output/{slug}", "meta": meta}
# Automatically generate or update per-project series_manifest.json
manifest = await _build_or_update_series_manifest(repo, project, slug, output_dir)

return {
"slug": slug,
"path": f"output/{slug}",
"meta": meta,
"manifest_path": f"output/{slug}/series_manifest.json"
}


@router.get("/{pid}/manifest")
async def get_project_manifest(pid: str):
"""Retrieve or auto-generate the project's series_manifest.json."""
repo = _get_repo()
project = await repo.get_project(pid)
if not project:
raise HTTPException(404, "Project not found")
project_name = getattr(project, "name", None) or (project.get("name") if isinstance(project, dict) else "")
slug = slugify(project_name)
output_dir = BASE_DIR / "output" / slug
output_dir.mkdir(parents=True, exist_ok=True)
return await _build_or_update_series_manifest(repo, project, slug, output_dir)



_ASPECT_RATIO_MAP = {
Expand Down
1 change: 1 addition & 0 deletions agent/db/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ async def create_request(req_type: str, orientation: str = None,
return await _get_with_db(db, "request", "id", rid)

async def get_request(rid: str): return await _get("request", "id", rid)
async def get_request_by_operation_id(op_id: str): return await _get("request", "request_id", op_id)
async def update_request(rid: str, **kw): return await _update("request", "id", rid, **kw)

async def list_requests(scene_id: str = None, status: str = None,
Expand Down
33 changes: 33 additions & 0 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,39 @@ async def ext_test_project_media():
}


@app.get("/api/ext/test-op")
async def ext_test_op(op: str):
"""Fetch raw operation status for an operation ID."""
client = get_flow_client()
if not client.connected:
from fastapi import HTTPException
raise HTTPException(503, "Extension not connected")
from agent.services import flow_batch as fb
res = await client.batch_rpc(fb.RPC_OPERATION, fb.operation_request(op))
return res


@app.get("/api/ext/test-op-match")
async def ext_test_op_match(op: str):
"""Test matching an operation in project media listing."""
client = get_flow_client()
if not client.connected:
from fastapi import HTTPException
raise HTTPException(503, "Extension not connected")
from agent.services import flow_batch as fb
pid = "594758cc-11f5-4f92-8b3c-1213686591f4"
res = await client.batch_rpc(fb.RPC_PROJECT_MEDIA, fb.project_media_request(pid), match=op)
raw = res.get("data", "")
found_mid = fb.find_media_id_in_text(raw, op)
return {
"status": res.get("status"),
"error": res.get("error"),
"raw_len": len(raw),
"raw": raw,
"found_mid": found_mid,
}


@app.get("/api/ext/media-url")
async def ext_media_url(media_id: str):
"""Fetch signed media URLs for a media_id."""
Expand Down
16 changes: 16 additions & 0 deletions agent/sdk/services/flow_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ def _omni_duration(requested) -> int:
logger = logging.getLogger(__name__)


async def _remember_op(client, op_name: str | None, pid: str | None) -> None:
if not client or not op_name or not pid:
return
fn = getattr(client, "_remember_operation", None)
if callable(fn):
res = fn(op_name, pid)
if asyncio.iscoroutine(res):
await res


def _save_raw_bytes(
operations: list[dict], scene_id: str, project_slug: str, display_order: int
) -> str | None:
Expand Down Expand Up @@ -394,6 +404,8 @@ async def _run_video(self, job: ProviderJob) -> dict:
# would abandon a running render and pay for a second one.
if existing_op:
logger.info("Video gen already submitted (op=%s), re-polling", existing_op[:30])
pid = ex.get("project_id") or (req_row.get("project_id") if req_row else None)
await _remember_op(self._client, existing_op, pid)
operations = [{"operation": {"name": existing_op}, "status": "MEDIA_GENERATION_STATUS_PENDING"}]
return await _poll_operations(self._client, operations)

Expand Down Expand Up @@ -438,6 +450,7 @@ async def _run_video(self, job: ProviderJob) -> dict:
return {"error": "Video gen returned no operations"}

op_name = operations[0].get("operation", {}).get("name", "")
await _remember_op(self._client, op_name, ex.get("project_id"))
if request_id:
await crud.update_request(request_id, request_id=op_name)

Expand Down Expand Up @@ -468,6 +481,8 @@ async def _run_video_refs(self, job: ProviderJob) -> dict:

if existing_op:
logger.info("R2V already submitted (op=%s), re-polling", existing_op[:30])
pid = ex.get("project_id") or (req_row.get("project_id") if req_row else None)
await _remember_op(self._client, existing_op, pid)
operations = [{"operation": {"name": existing_op}, "status": "MEDIA_GENERATION_STATUS_PENDING"}]
return await _poll_operations(self._client, operations)

Expand All @@ -488,6 +503,7 @@ async def _run_video_refs(self, job: ProviderJob) -> dict:
return {"error": "R2V returned no operations"}

op_name = operations[0].get("operation", {}).get("name", "")
await _remember_op(self._client, op_name, ex.get("project_id"))
if request_id:
await crud.update_request(request_id, request_id=op_name)

Expand Down
4 changes: 2 additions & 2 deletions agent/services/flow_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,8 @@ def find_media_id_in_text(text: str, operation_id: str) -> Optional[str]:
start = lower_text.find(lower_op)
if start == -1:
return None
# Search within window of match
window_start = max(0, start - 400)
# Search within window of match (from operation start onwards to avoid matching previous items)
window_start = start
window_end = min(len(text), start + 800)
match = _MEDIA_SLOT.search(text, window_start, window_end)
return match.group(1) if match else None
Expand Down
9 changes: 9 additions & 0 deletions agent/services/flow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,15 @@ async def _find_operation_media(self, operation_id: str) -> tuple[str | None, st
self._operation_polls[operation_id] = rounds

project_id = self._operation_projects.get(operation_id) or FLOW_PROJECT_ID
if not project_id:
try:
from agent.db import crud
req = await crud.get_request_by_operation_id(operation_id)
if req and req.get("project_id"):
project_id = req["project_id"]
self._remember_operation(operation_id, project_id)
except Exception:
pass
complaint = None
worth_looking = rounds % 3 == 0
try:
Expand Down
6 changes: 3 additions & 3 deletions agent/worker/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,12 +481,12 @@ async def _handle_failure(rid: str, req: dict, result: dict, retry_after: dict =
if isinstance(error_msg, dict):
error_msg = json.dumps(error_msg)[:200]

# Auto-recover expired media by re-uploading
if "not found" in str(error_msg).lower():
# Auto-recover expired media by re-uploading (only on real entity-not-found, never on polling timeout)
if "not found" in str(error_msg).lower() and "polling timeout" not in str(error_msg).lower():
recovered = await _recover_entity_not_found(req)
if recovered:
logger.info("Request %s: recovered expired media, retrying", rid[:8])
await crud.update_request(rid, status="PENDING", error_message=f"recovered: {error_msg}")
await crud.update_request(rid, status="PENDING", request_id=None, error_message=f"recovered: {error_msg}")
return

error_lower = str(error_msg).lower()
Expand Down
10 changes: 8 additions & 2 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,11 @@ function connectToAgent() {
bl: wiz.cfb2h,
hasGrecaptcha: !!window.grecaptcha?.enterprise?.execute,
executeString: window.grecaptcha?.enterprise?.execute?.toString(),
hasHijack: !!window.__fk_hijack,
hijackPristine: typeof window.__fk_hijack?.pristine,
hijackTrapped: !!window.__fk_hijack?.trapped,
hijackSource: window.__fk_hijack?.source,
grecaptchaClients: Object.keys(window.___grecaptcha_cfg?.clients || {}),
promptElements: Array.from(document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]'))
.map(el => ({ tag: el.tagName, id: el.id, className: el.className, placeholder: el.placeholder || el.getAttribute('aria-label') || el.getAttribute('placeholder') })),
editorContent: document.querySelector('.ProseMirror')?.innerText || '',
Expand Down Expand Up @@ -607,8 +612,8 @@ async function solveCaptcha(requestId, captchaAction) {
}

// Try each Flow tab in turn. A tab that answers "no grecaptcha" is a tab
// sitting on a page that never loaded it — another Flow tab may well be
// fine. Returning on the first one let one stale tab veto every generation.
// Try project tabs first (they have the full toolchain and recaptcha context loaded)
tabs.sort((a, b) => ((b.url && b.url.includes('/project/')) ? 1 : 0) - ((a.url && a.url.includes('/project/')) ? 1 : 0));
const errors = [];
for (const candidate of tabs) {
const tab = await reviveTabIfNeeded(candidate);
Expand Down Expand Up @@ -689,6 +694,7 @@ const MAX_RPC_TEXT = 32000000; // the project listing alone is past 17 MB
async function runBatchRpc(cmd) {
const tabs = await chrome.tabs.query({ url: flowUrls });
let candidate =
tabs.find((t) => t.url && t.url.includes('/project/') && !t.discarded) ||
tabs.find((t) => t.active && !t.discarded) ||
tabs.find((t) => !t.discarded) ||
tabs[0];
Expand Down
Loading
Loading