Gaurav vashistha commited on
Commit
05ae0b9
·
0 Parent(s):

Initial commit

Browse files
.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ GOOGLE_API_KEY=your_api_key_here
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .env
2
+ __pycache__
3
+ venv/
agent.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import TypedDict, Optional
3
+ from langgraph.graph import StateGraph, END
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+ from google import genai
6
+ from gradio_client import Client, handle_file
7
+ import shutil
8
+ import requests
9
+ import tempfile
10
+ import os
11
+ import shutil
12
+ import requests
13
+ import tempfile
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+
19
+ # State Definition
20
+ class ContinuityState(TypedDict):
21
+ video_a_url: str
22
+ video_c_url: str
23
+ user_notes: Optional[str]
24
+ scene_analysis: Optional[str]
25
+ veo_prompt: Optional[str]
26
+ generated_video_url: Optional[str]
27
+ video_a_local_path: Optional[str]
28
+ video_c_local_path: Optional[str]
29
+
30
+ # Node 1: Analyst
31
+ def analyze_videos(state: ContinuityState) -> dict:
32
+ print("--- 🧐 Analyst Node (Director) ---")
33
+
34
+ video_a_url = state['video_a_url']
35
+ video_c_url = state['video_c_url']
36
+
37
+ # Initialize Google GenAI Client
38
+ client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
39
+
40
+ try:
41
+ # Download videos to temp files for analysis
42
+ def download_to_temp(url):
43
+ print(f"Downloading: {url}")
44
+ resp = requests.get(url, stream=True)
45
+ resp.raise_for_status()
46
+ suffix = os.path.splitext(url.split("/")[-1])[1] or ".mp4"
47
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
48
+ shutil.copyfileobj(resp.raw, f)
49
+ return f.name
50
+
51
+ path_a = state.get('video_a_local_path')
52
+ if not path_a:
53
+ path_a = download_to_temp(video_a_url)
54
+
55
+ path_c = state.get('video_c_local_path')
56
+ if not path_c:
57
+ path_c = download_to_temp(video_c_url)
58
+
59
+ print("Uploading videos to Gemini...")
60
+ file_a = client.files.upload(file=path_a)
61
+ file_c = client.files.upload(file=path_c)
62
+
63
+ # Wait for processing? Usually quick for small files, but good practice to check state if needed.
64
+ # For simplicity in this agent, assuming ready or waiting implicitly.
65
+ # (Gemini 1.5 Flash usually processes quickly)
66
+
67
+ prompt = """
68
+ You are a film director.
69
+ Analyze the motion, lighting, and subject of the first video (Video A) and the second video (Video C).
70
+ Write a detailed visual prompt for a 2-second video (Video B) that smoothly transitions from the end of A to the start of C.
71
+ Target Output: A single concise descriptive paragraph for the video generation model.
72
+ """
73
+
74
+ print("Generating transition prompt...")
75
+ response = client.models.generate_content(
76
+ model="gemini-1.5-flash",
77
+ contents=[prompt, file_a, file_c]
78
+ )
79
+
80
+ transition_prompt = response.text
81
+ print(f"Generated Prompt: {transition_prompt}")
82
+
83
+ # Cleanup uploaded files from local ? (Files on server stay for 48h or until deleted)
84
+ # client.files.delete(name=file_a.name)
85
+ # client.files.delete(name=file_c.name)
86
+
87
+ # We also need these local paths for the Generator node to extract frames!
88
+ # Pass them in state or re-download? Better to pass paths if possible, but
89
+ # State definition expects URLs. We can add temp paths to state or re-download.
90
+ # Let's add temp paths to state for efficiency.
91
+
92
+ return {
93
+ "scene_analysis": transition_prompt,
94
+ "veo_prompt": transition_prompt,
95
+ "video_a_local_path": path_a,
96
+ "video_c_local_path": path_c
97
+ }
98
+
99
+ except Exception as e:
100
+ print(f"Error in Analyst: {e}")
101
+ return {"scene_analysis": f"Error: {str(e)}", "veo_prompt": "Error"}
102
+
103
+
104
+ # Node 2: Generator (Wan 2.2 First Last Frame)
105
+ def generate_video(state: ContinuityState) -> dict:
106
+ print("--- 🎥 Generator Node (Wan 2.2) ---")
107
+
108
+ prompt = state.get('veo_prompt', "")
109
+ path_a = state.get('video_a_local_path')
110
+ path_c = state.get('video_c_local_path')
111
+
112
+ if not path_a or not path_c:
113
+ # Fallback if dependencies failed or state clean
114
+ # Re-download logic would go here, but assuming flow works
115
+ return {"generated_video_url": "Error: Missing local video paths"}
116
+
117
+ try:
118
+ # Extract Frames
119
+ import cv2
120
+ from PIL import Image
121
+
122
+ def get_frame(video_path, location="last"):
123
+ cap = cv2.VideoCapture(video_path)
124
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
125
+ if location == "last":
126
+ cap.set(cv2.CAP_PROP_POS_FRAMES, total_frames - 1)
127
+ else: # first
128
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
129
+
130
+ ret, frame = cap.read()
131
+ cap.release()
132
+
133
+ if ret:
134
+ # Convert BGR to RGB
135
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
136
+ return Image.fromarray(frame_rgb)
137
+ else:
138
+ raise ValueError(f"Could not extract frame from {video_path}")
139
+
140
+ print("Extracting frames...")
141
+ img_start = get_frame(path_a, "last")
142
+ img_end = get_frame(path_c, "first")
143
+
144
+ # Save frames to temp files for Gradio Client (it handles file paths better than PIL objects usually)
145
+ # Although client.predict might take PIL, handle_file is safer with paths.
146
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f_start:
147
+ img_start.save(f_start, format="PNG")
148
+ start_path = f_start.name
149
+
150
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f_end:
151
+ img_end.save(f_end, format="PNG")
152
+ end_path = f_end.name
153
+
154
+ # Call Wan 2.2
155
+ print("Initializing Wan Client...")
156
+ client = Client("multimodalart/wan-2-2-first-last-frame")
157
+
158
+ print(f"Generating transition with prompt: {prompt[:50]}...")
159
+ # predict(start_image, end_image, prompt, negative_prompt, duration, steps, guide, guide2, seed, rand, api_name)
160
+ result = client.predict(
161
+ start_image_pil=handle_file(start_path),
162
+ end_image_pil=handle_file(end_path),
163
+ prompt=prompt,
164
+ negative_prompt="blurry, distorted, low quality, static",
165
+ duration_seconds=2.1,
166
+ steps=20, # Default is often around 20-30 for good quality
167
+ guidance_scale=5.0,
168
+ guidance_scale_2=5.0,
169
+ seed=42,
170
+ randomize_seed=True,
171
+ api_name="/generate_video"
172
+ )
173
+
174
+ # Clean up temp frames and videos
175
+ try:
176
+ os.remove(start_path)
177
+ os.remove(end_path)
178
+ os.remove(path_a)
179
+ os.remove(path_c)
180
+ except:
181
+ pass
182
+
183
+ # Parse valid result
184
+ # Expected: ({'video': path, ...}, seed) or just path depending on version
185
+ # Based on inspection: (generated_video_mp4, seed)
186
+ video_out = result[0]
187
+ if isinstance(video_out, dict) and 'video' in video_out:
188
+ return {"generated_video_url": video_out['video']}
189
+ elif isinstance(video_out, str) and os.path.exists(video_out):
190
+ return {"generated_video_url": video_out}
191
+ else:
192
+ return {"generated_video_url": f"Error: Unexpected output {result}"}
193
+
194
+ except Exception as e:
195
+ print(f"Error in Generator: {e}")
196
+ return {"generated_video_url": f"Error: {str(e)}"}
197
+
198
+
199
+ # Graph Construction
200
+ workflow = StateGraph(ContinuityState)
201
+
202
+ workflow.add_node("analyst", analyze_videos)
203
+ # workflow.add_node("prompter", draft_prompt) # Skipped, Analyst does extraction + prompting
204
+ workflow.add_node("generator", generate_video)
205
+
206
+ workflow.set_entry_point("analyst")
207
+
208
+ workflow.add_edge("analyst", "generator")
209
+ workflow.add_edge("generator", END)
210
+
211
+ app = workflow.compile()
continuity_agent/__init__.py ADDED
File without changes
continuity_agent/agent.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import TypedDict, Optional
3
+ from langgraph.graph import StateGraph, END
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+ from google import genai
6
+ from gradio_client import Client, handle_file
7
+ import shutil
8
+ import requests
9
+ import tempfile
10
+ import os
11
+ import shutil
12
+ import requests
13
+ import tempfile
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+
19
+ # State Definition
20
+ class ContinuityState(TypedDict):
21
+ video_a_url: str
22
+ video_c_url: str
23
+ user_notes: Optional[str]
24
+ scene_analysis: Optional[str]
25
+ veo_prompt: Optional[str]
26
+ generated_video_url: Optional[str]
27
+ video_a_local_path: Optional[str]
28
+ video_c_local_path: Optional[str]
29
+
30
+ # Node 1: Analyst
31
+ def analyze_videos(state: ContinuityState) -> dict:
32
+ print("--- 🧐 Analyst Node (Director) ---")
33
+
34
+ video_a_url = state['video_a_url']
35
+ video_c_url = state['video_c_url']
36
+
37
+ # Initialize Google GenAI Client
38
+ client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
39
+
40
+ try:
41
+ # Download videos to temp files for analysis
42
+ def download_to_temp(url):
43
+ print(f"Downloading: {url}")
44
+ resp = requests.get(url, stream=True)
45
+ resp.raise_for_status()
46
+ suffix = os.path.splitext(url.split("/")[-1])[1] or ".mp4"
47
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
48
+ shutil.copyfileobj(resp.raw, f)
49
+ return f.name
50
+
51
+ path_a = state.get('video_a_local_path')
52
+ if not path_a:
53
+ path_a = download_to_temp(video_a_url)
54
+
55
+ path_c = state.get('video_c_local_path')
56
+ if not path_c:
57
+ path_c = download_to_temp(video_c_url)
58
+
59
+ print("Uploading videos to Gemini...")
60
+ file_a = client.files.upload(file=path_a)
61
+ file_c = client.files.upload(file=path_c)
62
+
63
+ # Wait for processing? Usually quick for small files, but good practice to check state if needed.
64
+ # For simplicity in this agent, assuming ready or waiting implicitly.
65
+ # (Gemini 1.5 Flash usually processes quickly)
66
+
67
+ prompt = """
68
+ You are a film director.
69
+ Analyze the motion, lighting, and subject of the first video (Video A) and the second video (Video C).
70
+ Write a detailed visual prompt for a 2-second video (Video B) that smoothly transitions from the end of A to the start of C.
71
+ Target Output: A single concise descriptive paragraph for the video generation model.
72
+ """
73
+
74
+ print("Generating transition prompt...")
75
+ response = client.models.generate_content(
76
+ model="gemini-1.5-flash",
77
+ contents=[prompt, file_a, file_c]
78
+ )
79
+
80
+ transition_prompt = response.text
81
+ print(f"Generated Prompt: {transition_prompt}")
82
+
83
+ # Cleanup uploaded files from local ? (Files on server stay for 48h or until deleted)
84
+ # client.files.delete(name=file_a.name)
85
+ # client.files.delete(name=file_c.name)
86
+
87
+ # We also need these local paths for the Generator node to extract frames!
88
+ # Pass them in state or re-download? Better to pass paths if possible, but
89
+ # State definition expects URLs. We can add temp paths to state or re-download.
90
+ # Let's add temp paths to state for efficiency.
91
+
92
+ return {
93
+ "scene_analysis": transition_prompt,
94
+ "veo_prompt": transition_prompt,
95
+ "video_a_local_path": path_a,
96
+ "video_c_local_path": path_c
97
+ }
98
+
99
+ except Exception as e:
100
+ print(f"Error in Analyst: {e}")
101
+ return {"scene_analysis": f"Error: {str(e)}", "veo_prompt": "Error"}
102
+
103
+
104
+ # Node 2: Generator (Wan 2.2 First Last Frame)
105
+ def generate_video(state: ContinuityState) -> dict:
106
+ print("--- 🎥 Generator Node (Wan 2.2) ---")
107
+
108
+ prompt = state.get('veo_prompt', "")
109
+ path_a = state.get('video_a_local_path')
110
+ path_c = state.get('video_c_local_path')
111
+
112
+ if not path_a or not path_c:
113
+ # Fallback if dependencies failed or state clean
114
+ # Re-download logic would go here, but assuming flow works
115
+ return {"generated_video_url": "Error: Missing local video paths"}
116
+
117
+ try:
118
+ # Extract Frames
119
+ import cv2
120
+ from PIL import Image
121
+
122
+ def get_frame(video_path, location="last"):
123
+ cap = cv2.VideoCapture(video_path)
124
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
125
+ if location == "last":
126
+ cap.set(cv2.CAP_PROP_POS_FRAMES, total_frames - 1)
127
+ else: # first
128
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
129
+
130
+ ret, frame = cap.read()
131
+ cap.release()
132
+
133
+ if ret:
134
+ # Convert BGR to RGB
135
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
136
+ return Image.fromarray(frame_rgb)
137
+ else:
138
+ raise ValueError(f"Could not extract frame from {video_path}")
139
+
140
+ print("Extracting frames...")
141
+ img_start = get_frame(path_a, "last")
142
+ img_end = get_frame(path_c, "first")
143
+
144
+ # Save frames to temp files for Gradio Client (it handles file paths better than PIL objects usually)
145
+ # Although client.predict might take PIL, handle_file is safer with paths.
146
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f_start:
147
+ img_start.save(f_start, format="PNG")
148
+ start_path = f_start.name
149
+
150
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f_end:
151
+ img_end.save(f_end, format="PNG")
152
+ end_path = f_end.name
153
+
154
+ # Call Wan 2.2
155
+ print("Initializing Wan Client...")
156
+ client = Client("multimodalart/wan-2-2-first-last-frame")
157
+
158
+ print(f"Generating transition with prompt: {prompt[:50]}...")
159
+ # predict(start_image, end_image, prompt, negative_prompt, duration, steps, guide, guide2, seed, rand, api_name)
160
+ result = client.predict(
161
+ start_image_pil=handle_file(start_path),
162
+ end_image_pil=handle_file(end_path),
163
+ prompt=prompt,
164
+ negative_prompt="blurry, distorted, low quality, static",
165
+ duration_seconds=2.1,
166
+ steps=20, # Default is often around 20-30 for good quality
167
+ guidance_scale=5.0,
168
+ guidance_scale_2=5.0,
169
+ seed=42,
170
+ randomize_seed=True,
171
+ api_name="/generate_video"
172
+ )
173
+
174
+ # Clean up temp frames and videos
175
+ try:
176
+ os.remove(start_path)
177
+ os.remove(end_path)
178
+ os.remove(path_a)
179
+ os.remove(path_c)
180
+ except:
181
+ pass
182
+
183
+ # Parse valid result
184
+ # Expected: ({'video': path, ...}, seed) or just path depending on version
185
+ # Based on inspection: (generated_video_mp4, seed)
186
+ video_out = result[0]
187
+ if isinstance(video_out, dict) and 'video' in video_out:
188
+ return {"generated_video_url": video_out['video']}
189
+ elif isinstance(video_out, str) and os.path.exists(video_out):
190
+ return {"generated_video_url": video_out}
191
+ else:
192
+ return {"generated_video_url": f"Error: Unexpected output {result}"}
193
+
194
+ except Exception as e:
195
+ print(f"Error in Generator: {e}")
196
+ return {"generated_video_url": f"Error: {str(e)}"}
197
+
198
+
199
+ # Graph Construction
200
+ workflow = StateGraph(ContinuityState)
201
+
202
+ workflow.add_node("analyst", analyze_videos)
203
+ # workflow.add_node("prompter", draft_prompt) # Skipped, Analyst does extraction + prompting
204
+ workflow.add_node("generator", generate_video)
205
+
206
+ workflow.set_entry_point("analyst")
207
+
208
+ workflow.add_edge("analyst", "generator")
209
+ workflow.add_edge("generator", END)
210
+
211
+ app = workflow.compile()
main.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+ import uvicorn
5
+ import os
6
+
7
+ from agent import app as agent_app
8
+
9
+ app = FastAPI(title="Continuity", description="AI Video Bridging Service")
10
+
11
+ class BridgeRequest(BaseModel):
12
+ url_a: str
13
+ url_c: str
14
+ notes: Optional[str] = None
15
+
16
+ @app.post("/create-bridge")
17
+ async def create_bridge(request: BridgeRequest):
18
+ """
19
+ Orchestrates the creation of a bridge video between two input clips.
20
+ """
21
+ try:
22
+ # Initialize LangGraph state
23
+ initial_state = {
24
+ "video_a_url": request.url_a,
25
+ "video_c_url": request.url_c,
26
+ "user_notes": request.notes,
27
+ "scene_analysis": None,
28
+ "veo_prompt": None,
29
+ "generated_video_url": None
30
+ }
31
+
32
+ print(f"Starting bridge generation for: {request.url_a} -> {request.url_c}")
33
+
34
+ # Invoke the graph
35
+ result = agent_app.invoke(initial_state)
36
+
37
+ video_url = result.get("generated_video_url")
38
+ analysis = result.get("scene_analysis")
39
+
40
+ # Check for error strings in the URL field as per agent logic
41
+ if video_url and "Error" in video_url:
42
+ raise HTTPException(status_code=500, detail=video_url)
43
+
44
+ if not video_url:
45
+ raise HTTPException(status_code=500, detail="Failed to generate video (No URL returned)")
46
+
47
+ return {
48
+ "video_url": video_url,
49
+ "analysis_summary": analysis
50
+ }
51
+
52
+ except Exception as e:
53
+ # Catch unexpected errors
54
+ raise HTTPException(status_code=500, detail=str(e))
55
+
56
+ if __name__ == "__main__":
57
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-dotenv
4
+ langgraph
5
+ langchain-google-genai
6
+ gradio_client
7
+ pydantic
8
+ requests
9
+
10
+ opencv-python
11
+ google-genai
12
+ python-multipart
schemas.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, HttpUrl
2
+ from typing import Optional
3
+
4
+ class VideoInput(BaseModel):
5
+ video_url_1: str
6
+ video_url_2: str
7
+ user_notes: Optional[str] = "Make it cinematic"
8
+
9
+ class VideoOutput(BaseModel):
10
+ bridging_video_url: str
server.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, UploadFile, Form, File
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+ import uvicorn
5
+ import os
6
+ import shutil
7
+ import uuid
8
+
9
+ # Import from the subpackage as before
10
+ from continuity_agent.agent import continuity_graph
11
+
12
+ app = FastAPI(title="Continuity", description="AI Video Bridging Service")
13
+
14
+ # 1. Enable CORS
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"],
18
+ allow_credentials=True,
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ # 2. Setup Static Files for Outputs
24
+ OUTPUT_DIR = "outputs"
25
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
26
+ app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
27
+
28
+ @app.post("/generate-transition")
29
+ async def generate_transition(
30
+ video_a: UploadFile = File(...),
31
+ video_c: UploadFile = File(...),
32
+ prompt: str = Form("Cinematic transition")
33
+ ):
34
+ try:
35
+ # Generate unique ID for this request
36
+ request_id = str(uuid.uuid4())
37
+
38
+ # Save inputs
39
+ # Preserve extension
40
+ ext_a = os.path.splitext(video_a.filename)[1] or ".mp4"
41
+ ext_c = os.path.splitext(video_c.filename)[1] or ".mp4"
42
+
43
+ path_a = os.path.join(OUTPUT_DIR, f"{request_id}_a{ext_a}")
44
+ path_c = os.path.join(OUTPUT_DIR, f"{request_id}_c{ext_c}")
45
+
46
+ with open(path_a, "wb") as buffer:
47
+ shutil.copyfileobj(video_a.file, buffer)
48
+
49
+ with open(path_c, "wb") as buffer:
50
+ shutil.copyfileobj(video_c.file, buffer)
51
+
52
+ # Initialize State with LOCAL PATHS
53
+ # We don't need URLs for the new logic, but state definition might map them.
54
+ # agent.py logic checks video_a_local_path if present.
55
+ initial_state = {
56
+ "video_a_url": "local_upload", # Placeholder
57
+ "video_c_url": "local_upload",
58
+ "user_notes": prompt,
59
+ "veo_prompt": prompt,
60
+ "video_a_local_path": os.path.abspath(path_a),
61
+ "video_c_local_path": os.path.abspath(path_c),
62
+ "generated_video_url": "",
63
+ "status": "started"
64
+ }
65
+
66
+ # Invoke Agent
67
+ result = continuity_graph.invoke(initial_state)
68
+
69
+ # The agent returns 'generated_video_url' which is a local absolute path (e.g., from tempfile or cache)
70
+ # We need to copy/move this to our STATIC directory to serve it.
71
+ gen_path = result.get("generated_video_url")
72
+
73
+ if not gen_path or "Error" in gen_path:
74
+ raise HTTPException(status_code=500, detail=f"Generation failed: {gen_path}")
75
+
76
+ # Copy generated video to outputs
77
+ final_filename = f"{request_id}_bridge.mp4"
78
+ final_output_path = os.path.join(OUTPUT_DIR, final_filename)
79
+
80
+ shutil.copy(gen_path, final_output_path)
81
+
82
+ # Return URL relative to server root
83
+ return {"video_url": f"/outputs/{final_filename}"}
84
+
85
+ except Exception as e:
86
+ print(f"Server Error: {e}")
87
+ raise HTTPException(status_code=500, detail=str(e))
88
+
89
+ if __name__ == "__main__":
90
+ uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=True)
state.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, Optional
2
+
3
+ class AgentState(TypedDict):
4
+ video_1_url: str
5
+ video_2_url: str
6
+ analysis_1: Optional[str]
7
+ analysis_2: Optional[str]
8
+ bridging_prompt: Optional[str]
9
+ generated_video_path: Optional[str]
stitch_continuity_dashboard/code.html ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+
3
+ <html class="dark" lang="en"><head>
4
+ <meta charset="utf-8"/>
5
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
6
+ <title>Continuity Dashboard</title>
7
+ <!-- Google Fonts -->
8
+ <link href="https://fonts.googleapis.com" rel="preconnect"/>
9
+ <link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&amp;display=swap" rel="stylesheet"/>
11
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;600;700&amp;display=swap" rel="stylesheet"/>
12
+ <!-- Material Symbols -->
13
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
14
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
15
+ <!-- Tailwind CSS -->
16
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
17
+ <script id="tailwind-config">
18
+ tailwind.config = {
19
+ darkMode: "class",
20
+ theme: {
21
+ extend: {
22
+ colors: {
23
+ "primary": "#7f0df2",
24
+ "background-light": "#f7f5f8",
25
+ "background-dark": "#191022",
26
+ "surface-dark": "#2a1d35",
27
+ "border-dark": "#4d3168",
28
+ },
29
+ fontFamily: {
30
+ "display": ["Space Grotesk", "sans-serif"],
31
+ "body": ["Noto Sans", "sans-serif"],
32
+ },
33
+ borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
34
+ boxShadow: {
35
+ "neon": "0 0 20px rgba(127, 13, 242, 0.4)",
36
+ "neon-strong": "0 0 35px rgba(127, 13, 242, 0.6)",
37
+ }
38
+ },
39
+ },
40
+ }
41
+ </script>
42
+ <style>
43
+ /* Custom scrollbar for dark theme */
44
+ ::-webkit-scrollbar {
45
+ width: 8px;
46
+ height: 8px;
47
+ }
48
+ ::-webkit-scrollbar-track {
49
+ background: #191022;
50
+ }
51
+ ::-webkit-scrollbar-thumb {
52
+ background: #4d3168;
53
+ border-radius: 4px;
54
+ }
55
+ ::-webkit-scrollbar-thumb:hover {
56
+ background: #7f0df2;
57
+ }
58
+ .glass-panel {
59
+ background: rgba(25, 16, 34, 0.6);
60
+ backdrop-filter: blur(12px);
61
+ -webkit-backdrop-filter: blur(12px);
62
+ border: 1px solid rgba(255, 255, 255, 0.05);
63
+ }
64
+ .connection-line {
65
+ height: 2px;
66
+ flex-grow: 1;
67
+ background: linear-gradient(90deg, #4d3168 0%, #7f0df2 50%, #4d3168 100%);
68
+ opacity: 0.5;
69
+ position: relative;
70
+ }
71
+ .connection-line::after {
72
+ content: '';
73
+ position: absolute;
74
+ right: 0;
75
+ top: 50%;
76
+ transform: translateY(-50%);
77
+ width: 6px;
78
+ height: 6px;
79
+ background: #7f0df2;
80
+ border-radius: 50%;
81
+ box-shadow: 0 0 10px #7f0df2;
82
+ }
83
+ </style>
84
+ </head>
85
+ <body class="relative flex h-screen w-full flex-col bg-background-light dark:bg-background-dark font-display overflow-hidden text-white selection:bg-primary selection:text-white">
86
+ <!-- Background Ambient Glows -->
87
+ <div class="fixed top-0 left-0 w-full h-full pointer-events-none overflow-hidden z-0">
88
+ <div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/20 rounded-full blur-[120px]"></div>
89
+ <div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-600/10 rounded-full blur-[120px]"></div>
90
+ </div>
91
+ <!-- Header -->
92
+ <header class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between whitespace-nowrap border-b border-white/5 glass-panel px-8 py-4">
93
+ <div class="flex items-center gap-4 text-white">
94
+ <div class="size-8 flex items-center justify-center text-primary">
95
+ <span class="material-symbols-outlined text-3xl">emergency_recording</span>
96
+ </div>
97
+ <h2 class="text-white text-xl font-bold leading-tight tracking-[-0.015em] bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400">
98
+ Continuity <span class="text-xs font-normal text-gray-400 ml-2 opacity-60 tracking-widest uppercase">The Missing Link</span>
99
+ </h2>
100
+ </div>
101
+ <div class="flex gap-3">
102
+ <button class="flex size-10 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-surface-dark hover:bg-primary/20 transition-colors border border-white/5 text-white">
103
+ <span class="material-symbols-outlined text-[20px]">settings</span>
104
+ </button>
105
+ <button class="flex size-10 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-surface-dark hover:bg-primary/20 transition-colors border border-white/5 text-white">
106
+ <span class="material-symbols-outlined text-[20px]">account_circle</span>
107
+ </button>
108
+ </div>
109
+ </header>
110
+ <!-- Main Workspace -->
111
+ <main class="relative z-10 flex flex-1 flex-col items-center justify-center w-full px-8 pt-20 pb-28">
112
+ <!-- Timeline Container -->
113
+ <div class="w-full max-w-[1400px] flex items-center justify-center gap-6 xl:gap-10">
114
+ <!-- SCENE A: Upload State -->
115
+ <div class="flex flex-col gap-4 flex-1 max-w-[320px] group">
116
+ <div class="flex items-center justify-between px-1">
117
+ <span class="text-xs font-bold tracking-widest text-gray-400 uppercase">Input Source</span>
118
+ <span class="text-xs font-bold tracking-widest text-primary">SCENE A</span>
119
+ </div>
120
+ <div class="relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed border-border-dark bg-surface-dark/30 hover:bg-surface-dark/50 hover:border-primary/50 transition-all duration-300 px-6 py-12 h-[360px]">
121
+ <div class="size-16 rounded-full bg-surface-dark flex items-center justify-center mb-2 shadow-lg group-hover:scale-110 transition-transform">
122
+ <span class="material-symbols-outlined text-3xl text-gray-300 group-hover:text-white">video_file</span>
123
+ </div>
124
+ <div class="flex flex-col items-center gap-2 text-center">
125
+ <p class="text-white text-lg font-bold">Start Scene</p>
126
+ <p class="text-gray-400 text-sm max-w-[200px]">Drag &amp; drop your starting video clip here</p>
127
+ </div>
128
+ <input type="file" id="video-upload-a" accept="video/*,image/*" class="hidden" onchange="handleFileSelect(this, 'label-a')">
129
+ <button onclick="document.getElementById('video-upload-a').click()" class="mt-2 flex items-center justify-center rounded-lg h-9 px-4 bg-surface-dark hover:bg-primary hover:text-white border border-white/10 transition-all text-sm font-bold tracking-wide text-gray-200 shadow-lg">
130
+ <span class="material-symbols-outlined text-[18px] mr-2">upload</span>
131
+ <span id="label-a">Select File</span>
132
+ </button>
133
+ </div>
134
+ </div>
135
+ <!-- Connector Left -->
136
+ <div class="hidden md:flex flex-col items-center justify-center w-16 xl:w-24 opacity-40">
137
+ <div class="w-full h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent relative">
138
+ <div class="absolute right-0 -top-1.5 text-primary animate-pulse">
139
+ <span class="material-symbols-outlined text-lg">chevron_right</span>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ <!-- THE BRIDGE: AI Generation Core -->
144
+ <div class="flex flex-col gap-4 flex-[1.2] max-w-[480px] relative z-20">
145
+ <div class="flex items-center justify-center px-1">
146
+ <span class="text-xs font-bold tracking-[0.2em] text-primary drop-shadow-[0_0_8px_rgba(127,13,242,0.8)] uppercase animate-pulse">The Bridge</span>
147
+ </div>
148
+ <!-- Active Card Container -->
149
+ <div class="relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-neon border border-primary/30 group">
150
+ <!-- Placeholder Gradient Background -->
151
+ <div class="absolute inset-0 bg-gradient-to-br from-surface-dark to-[#0f0a16] z-0"></div>
152
+ <!-- Abstract Visualization -->
153
+ <div class="absolute inset-0 opacity-40 mix-blend-overlay bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&amp;w=1000&amp;auto=format&amp;fit=crop')] bg-cover bg-center" data-alt="Abstract digital waves representing AI processing"></div>
154
+ <!-- Glowing Core Animation (CSS Only representation) -->
155
+ <div class="absolute inset-0 flex items-center justify-center">
156
+ <div class="relative size-32">
157
+ <div class="absolute inset-0 rounded-full border-2 border-primary/20 animate-[spin_4s_linear_infinite]"></div>
158
+ <div class="absolute inset-2 rounded-full border-2 border-t-primary border-r-transparent border-b-primary/50 border-l-transparent animate-[spin_3s_linear_infinite_reverse]"></div>
159
+ <div class="absolute inset-0 flex items-center justify-center">
160
+ <span class="material-symbols-outlined text-4xl text-primary drop-shadow-[0_0_10px_rgba(127,13,242,1)]">auto_awesome</span>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ <!-- Content Overlay -->
165
+ <div class="absolute bottom-0 left-0 w-full p-6 bg-gradient-to-t from-black/90 via-black/50 to-transparent flex items-end justify-between">
166
+ <div class="flex flex-col gap-1">
167
+ <p class="text-white text-xl font-bold leading-tight">AI Transition</p>
168
+ <div class="flex items-center gap-2">
169
+ <div class="size-2 rounded-full bg-yellow-500 animate-pulse"></div>
170
+ <p class="text-gray-300 text-xs font-medium uppercase tracking-wide">Waiting for inputs...</p>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ <!-- Connector Right -->
177
+ <div class="hidden md:flex flex-col items-center justify-center w-16 xl:w-24 opacity-40">
178
+ <div class="w-full h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent relative">
179
+ <div class="absolute right-0 -top-1.5 text-primary animate-pulse">
180
+ <span class="material-symbols-outlined text-lg">chevron_right</span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ <!-- SCENE C: Upload State -->
185
+ <div class="flex flex-col gap-4 flex-1 max-w-[320px] group">
186
+ <div class="flex items-center justify-between px-1">
187
+ <span class="text-xs font-bold tracking-widest text-primary">SCENE C</span>
188
+ <span class="text-xs font-bold tracking-widest text-gray-400 uppercase">Target Source</span>
189
+ </div>
190
+ <div class="relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed border-border-dark bg-surface-dark/30 hover:bg-surface-dark/50 hover:border-primary/50 transition-all duration-300 px-6 py-12 h-[360px]">
191
+ <div class="size-16 rounded-full bg-surface-dark flex items-center justify-center mb-2 shadow-lg group-hover:scale-110 transition-transform">
192
+ <span class="material-symbols-outlined text-3xl text-gray-300 group-hover:text-white">movie_edit</span>
193
+ </div>
194
+ <div class="flex flex-col items-center gap-2 text-center">
195
+ <p class="text-white text-lg font-bold">End Scene</p>
196
+ <p class="text-gray-400 text-sm max-w-[200px]">Drag &amp; drop your target video clip here</p>
197
+ </div>
198
+ <input type="file" id="video-upload-c" accept="video/*,image/*" class="hidden" onchange="handleFileSelect(this, 'label-c')">
199
+ <button onclick="document.getElementById('video-upload-c').click()" class="mt-2 flex items-center justify-center rounded-lg h-9 px-4 bg-surface-dark hover:bg-primary hover:text-white border border-white/10 transition-all text-sm font-bold tracking-wide text-gray-200 shadow-lg">
200
+ <span class="material-symbols-outlined text-[18px] mr-2">upload</span>
201
+ <span id="label-c">Select File</span>
202
+ </button>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </main>
207
+ <!-- Floating Action Bar (Director Controls) -->
208
+ <div class="fixed bottom-10 left-1/2 -translate-x-1/2 z-50 w-full max-w-2xl px-4">
209
+ <div class="glass-panel rounded-full p-2 pl-6 flex items-center shadow-neon">
210
+ <div class="flex-1 flex items-center gap-3">
211
+ <span class="material-symbols-outlined text-gray-400">edit_note</span>
212
+ <input id="director-notes" class="w-full bg-transparent border-none text-white placeholder-gray-500 focus:ring-0 text-sm font-medium" placeholder="Add Director Notes (e.g., 'Slow dissolve', 'Cyber glitch')..." type="text"/>
213
+ </div>
214
+ <div class="h-8 w-[1px] bg-white/10 mx-2"></div>
215
+ <button id="generate-btn" class="flex items-center gap-2 bg-primary hover:bg-[#6b0bc9] text-white px-6 py-3 rounded-full font-bold text-sm transition-all shadow-[0_0_15px_rgba(127,13,242,0.4)] hover:shadow-[0_0_25px_rgba(127,13,242,0.6)] whitespace-nowrap">
216
+ <span class="material-symbols-outlined text-[20px]">movie_filter</span>
217
+ Generate Transition
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ <script>
223
+ // UI Helper: Update filename text when file is selected
224
+ function handleFileSelect(input, labelId) {
225
+ if (input.files && input.files[0]) {
226
+ const label = document.getElementById(labelId);
227
+ label.innerText = input.files[0].name;
228
+ label.classList.add("text-primary"); // Turn purple to indicate success
229
+ }
230
+ }
231
+
232
+ // Main Logic: Send to Agent
233
+ const generateBtn = document.getElementById("generate-btn");
234
+ if (generateBtn) {
235
+ generateBtn.addEventListener("click", async () => {
236
+ const fileA = document.getElementById("video-upload-a").files[0];
237
+ const fileC = document.getElementById("video-upload-c").files[0];
238
+ const notes = document.getElementById("director-notes").value;
239
+ const btn = document.getElementById("generate-btn");
240
+
241
+ // 1. Validation
242
+ if (!fileA || !fileC) {
243
+ alert("⚠️ Action Required: Please upload both Scene A and Scene C videos.");
244
+ return;
245
+ }
246
+
247
+ // 2. Loading State
248
+ const originalContent = btn.innerHTML;
249
+ btn.innerHTML = `<span class="material-symbols-outlined animate-spin text-[20px] mr-2">progress_activity</span> Director is working...`;
250
+ btn.disabled = true;
251
+ btn.classList.add("opacity-70", "cursor-not-allowed");
252
+
253
+ const formData = new FormData();
254
+ formData.append("video_a", fileA);
255
+ formData.append("video_c", fileC);
256
+ formData.append("prompt", notes || "Cinematic transition between scenes");
257
+
258
+ try {
259
+ // 3. Send to Server
260
+ const response = await fetch("http://127.0.0.1:8000/generate-transition", {
261
+ method: "POST",
262
+ body: formData
263
+ });
264
+
265
+ if (!response.ok) throw new Error("Agent backend returned an error.");
266
+
267
+ const data = await response.json();
268
+
269
+ // 4. Success: Inject Video into the Middle Card
270
+ // We target the middle "Bridge" div
271
+ const bridgeCard = document.querySelector(".aspect-\\[4\\/3\\]");
272
+ // Find the middle card specifically if possible, relying on the class is risky if multiple match.
273
+ // But in this layout, the middle one is distinct or we can target by structure.
274
+ // The middle card is the second .aspect-[4/3] roughly, but the user script uses querySelector which matches the first one?
275
+ // Wait, the Code.html has 3 cards.
276
+ // Scene A: .aspect-[4/3]
277
+ // Bridge: .aspect-[4/3] (line 148)
278
+ // Scene C: .aspect-[4/3]
279
+
280
+ // User script used document.querySelector(".aspect-\\[4\\/3\\]").
281
+ // This matches the first one (Scene A)! That's a bug in the user's snippet.
282
+ // I will fix it to target the BRIDGE card.
283
+ // The bridge card container has text "The Bridge".
284
+ // I'll add an ID to the bridge container or select by index.
285
+ // Safer to select the middle one: document.querySelectorAll(".aspect-\\[4\\/3\\]")[1]
286
+
287
+ const cards = document.querySelectorAll(".aspect-\\[4\\/3\\]");
288
+ if (cards.length >= 2) {
289
+ const bridgeCard = cards[1];
290
+ bridgeCard.innerHTML = `
291
+ <video controls autoplay loop class="w-full h-full object-cover rounded-2xl border-2 border-primary shadow-neon">
292
+ <source src="http://127.0.0.1:8000${data.video_url}" type="video/mp4">
293
+ Your browser does not support the video tag.
294
+ </video>
295
+ `;
296
+ }
297
+
298
+ } catch (error) {
299
+ console.error(error);
300
+ alert("❌ Generation Failed: " + error.message + "\n\nMake sure 'python server.py' is running!");
301
+ } finally {
302
+ btn.innerHTML = originalContent;
303
+ btn.disabled = false;
304
+ btn.classList.remove("opacity-70", "cursor-not-allowed");
305
+ }
306
+ });
307
+ }
308
+ </script>
309
+ </body></html>
sync_agent.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import shutil
2
+ shutil.copy("agent.py", "continuity_agent/agent.py")
3
+ print("Synced agent.py")