flmkr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flmkr/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """FLMKR — AI Filmmaking Harness."""
2
+
3
+ from flmkr.exceptions import (
4
+ FLMKRError,
5
+ InvalidSpec,
6
+ ProviderError,
7
+ QARejection,
8
+ RetryExhausted,
9
+ StateConflict,
10
+ )
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "FLMKRError",
15
+ "ProviderError",
16
+ "QARejection",
17
+ "StateConflict",
18
+ "RetryExhausted",
19
+ "InvalidSpec",
20
+ ]
flmkr/agents.py ADDED
@@ -0,0 +1,530 @@
1
+ """Abstract agent interface and concrete agent implementations for FLMKR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from flmkr.llm import LLMMessage, get_llm
11
+ from flmkr.models import Bible, NarrFunction
12
+
13
+
14
+ @dataclass
15
+ class AgentTask:
16
+ """Input contract for any agent invocation."""
17
+
18
+ task_id: str
19
+ project_id: str
20
+ agent: str
21
+ objective: str
22
+ relevant_bible: dict[str, Any] = field(default_factory=dict)
23
+ context: dict[str, Any] = field(default_factory=dict)
24
+ constraints: dict[str, Any] = field(default_factory=dict)
25
+ dependencies: list[str] = field(default_factory=list)
26
+
27
+
28
+ @dataclass
29
+ class AgentResult:
30
+ """Output contract for any agent invocation."""
31
+
32
+ task_id: str
33
+ status: str # "completed", "failed", "needs_review"
34
+ result: dict[str, Any] = field(default_factory=dict)
35
+ artifacts: list[str] = field(default_factory=list)
36
+ bible_delta: dict[str, Any] = field(default_factory=dict)
37
+ new_entities: list[dict[str, Any]] = field(default_factory=list)
38
+ risks: list[str] = field(default_factory=list)
39
+ questions: list[str] = field(default_factory=list)
40
+ recommendations: list[str] = field(default_factory=list)
41
+
42
+
43
+ class BaseAgent(ABC):
44
+ """Abstract base for all FLMKR agents."""
45
+
46
+ name: str = "base"
47
+ identity: str = ""
48
+ capabilities: list[str] = []
49
+ bible_permissions: dict[str, str] = field(default_factory=dict) # "read" / "write"
50
+ retry_policy: dict[str, Any] = field(default_factory=dict)
51
+
52
+ @abstractmethod
53
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
54
+ """Execute the agent's task against the production bible."""
55
+ ...
56
+
57
+ def validate_output(self, result: AgentResult) -> bool:
58
+ """Validate agent output conforms to schema requirements."""
59
+ return bool(result.status and result.task_id)
60
+
61
+
62
+ class ProducerAgent(BaseAgent):
63
+ """Top-level orchestrator agent — coordinates all other agents."""
64
+
65
+ name = "producer"
66
+ identity = "Producer / Director"
67
+ capabilities = [
68
+ "orchestrate",
69
+ "plan",
70
+ "approve",
71
+ "escalate",
72
+ "resume",
73
+ "recover",
74
+ "cost_track",
75
+ "runtime_budget",
76
+ ]
77
+ bible_permissions = {
78
+ "story": "read",
79
+ "characters": "read",
80
+ "world": "read",
81
+ "style": "read",
82
+ "shots": "read",
83
+ "assets": "read",
84
+ "decisions": "write",
85
+ }
86
+
87
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
88
+ return AgentResult(
89
+ task_id=task.task_id,
90
+ status="completed",
91
+ result={"message": "Producer coordination complete"},
92
+ )
93
+
94
+
95
+ class StoryAgent(BaseAgent):
96
+ """Responsible for screenplay normalization and scene creation."""
97
+
98
+ name = "story"
99
+ identity = "Story Agent"
100
+ capabilities = ["normalize", "screenplay", "scenes", "beats", "dialogue"]
101
+ bible_permissions = {
102
+ "story": "write",
103
+ "characters": "read",
104
+ "world": "read",
105
+ }
106
+
107
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
108
+ return AgentResult(
109
+ task_id=task.task_id,
110
+ status="completed",
111
+ result={"message": "Story normalization complete"},
112
+ bible_delta={"story": {"title": bible.title, "logline": bible.logline}},
113
+ )
114
+
115
+
116
+ class CharacterAgent(BaseAgent):
117
+ """Defines characters, visual identity, and continuity."""
118
+
119
+ name = "character"
120
+ identity = "Casting / Character Agent"
121
+ capabilities = ["define_character", "create_reference", "wardrobe", "arc"]
122
+ bible_permissions = {
123
+ "characters": "write",
124
+ "story": "read",
125
+ "style": "read",
126
+ }
127
+
128
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
129
+ return AgentResult(
130
+ task_id=task.task_id,
131
+ status="completed",
132
+ result={"message": "Character definitions complete"},
133
+ )
134
+
135
+
136
+ class CinematographyAgent(BaseAgent):
137
+ """Converts scenes to shot lists with camera language."""
138
+
139
+ name = "cinematography"
140
+ identity = "Cinematography / Blocking Agent"
141
+ capabilities = ["shot_plan", "framing", "camera_motion", "blocking", "lens"]
142
+ bible_permissions = {
143
+ "story": "read",
144
+ "characters": "read",
145
+ "locations": "read",
146
+ "style": "read",
147
+ "shots": "write",
148
+ }
149
+
150
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
151
+ scene_description = task.objective or ""
152
+ context_chars = list(bible.characters.values())
153
+ context_locs = list(bible.locations.values())
154
+ existing_shots = bible.shot_ledger
155
+ scene_id = task.context.get("scene_id", "")
156
+ scene = bible.scenes.get(scene_id) if scene_id else None
157
+ scene_info = (
158
+ f"Scene {scene.id}: '{scene.title}' - {scene.synopsis} "
159
+ f"(target {scene.target_duration:.1f}s)"
160
+ if scene
161
+ else "Unassigned scene"
162
+ )
163
+
164
+ system_prompt = (
165
+ "You are a Cinematography Director planning shots for a film production.\n"
166
+ "Output a JSON array of shot objects. Each shot must have:\n"
167
+ "- id: format 'SXX_SHYY' (e.g. S01_SH01), auto-increment from existing shots\n"
168
+ "- scene_id: the scene this shot belongs to\n"
169
+ "- description: one-line visual description\n"
170
+ "- framing: wide | medium | close-up | extreme-close-up | two-shot\n"
171
+ "- duration: float in seconds (sum should respect target_duration when provided)\n"
172
+ "- camera_motion: static | pan | tilt | dolly | truck | zoom | handheld | crane | orbital\n"
173
+ "- lens: e.g. '35mm anamorphic', '85mm prime', '24mm wide'\n"
174
+ "- lighting: concise lighting description\n"
175
+ "- narrative_function: establish | reveal | conflict | response | transition | emotion | info | climax\n"
176
+ "- characters: list of character IDs present in this shot\n"
177
+ "- location_id: the location ID for this shot\n"
178
+ "Constraints:\n"
179
+ "- Be visually specific and cinematic.\n"
180
+ "- Match the style constraints in the scene synopsis and character descriptions.\n"
181
+ "- Do not repeat identical framings consecutively without a clear reason.\n"
182
+ "- Return ONLY valid JSON, no markdown fences."
183
+ )
184
+
185
+ chars_part = (
186
+ "; ".join(
187
+ f"{c.name}({c.id}) - {c.physical_description or c.consistency_token}"
188
+ for c in context_chars
189
+ )
190
+ or "(none defined)"
191
+ )
192
+ locs_part = (
193
+ "; ".join(f"{loc.name}({loc.id}) - {loc.description}" for loc in context_locs)
194
+ or "(none defined)"
195
+ )
196
+ existing_ids = "; ".join(s.id for s in existing_shots[-10:]) if existing_shots else "(none)"
197
+
198
+ user_prompt = (
199
+ f"Project: {bible.title}\n"
200
+ f"Logline: {bible.logline}\n"
201
+ f"Tone: {bible.tone} | Genre: {bible.genre}\n"
202
+ f"Target runtime: {bible.target_runtime}s\n"
203
+ f"Aspect ratio: {bible.style.aspect_ratio}\n"
204
+ f"Style guide: {bible.style.visual_language}\n"
205
+ f"{scene_info}\n"
206
+ f"Scene description / directive: {scene_description}\n"
207
+ f"Characters ({len(context_chars)}): {chars_part}\n"
208
+ f"Locations ({len(context_locs)}): {locs_part}\n"
209
+ f"Existing shots in ledger ({len(existing_shots)}): {existing_ids}\n"
210
+ "\nPlan the shots now."
211
+ )
212
+
213
+ llm = get_llm(provider="agnes")
214
+ messages = [
215
+ LLMMessage(role="system", content=system_prompt),
216
+ LLMMessage(role="user", content=user_prompt),
217
+ ]
218
+
219
+ try:
220
+ result_json = await llm.chat_json(
221
+ messages,
222
+ schema={"type": "array", "items": {"type": "object"}},
223
+ )
224
+ shots_data: list[dict[str, Any]] = result_json if isinstance(result_json, list) else []
225
+ if not shots_data:
226
+ shots_data = self._fallback_shot_plan(scene_description, bible, scene)
227
+
228
+ return AgentResult(
229
+ task_id=task.task_id,
230
+ status="completed",
231
+ result={"message": f"Shot plan complete - {len(shots_data)} shots generated"},
232
+ bible_delta={"shots": shots_data},
233
+ )
234
+ except Exception as exc:
235
+ fallback = self._fallback_shot_plan(scene_description, bible, scene)
236
+ return AgentResult(
237
+ task_id=task.task_id,
238
+ status="completed",
239
+ result={
240
+ "message": f"Shot plan complete (LLM error: {exc}; used fallback)",
241
+ "llm_error": str(exc),
242
+ },
243
+ bible_delta={"shots": fallback},
244
+ risks=[f"LLM unavailable during cinematography planning: {exc}"],
245
+ )
246
+
247
+ @staticmethod
248
+ def _fallback_shot_plan(
249
+ scene_desc: str, bible: Bible, scene: Any | None
250
+ ) -> list[dict[str, Any]]:
251
+ """Deterministic fallback when LLM is unavailable."""
252
+ target = scene.target_duration if scene else 10.0
253
+ scene_num = int(target // 5) + 1
254
+ base_id = f"S{scene_num:02d}"
255
+ shots: list[dict[str, Any]] = [
256
+ {
257
+ "id": f"{base_id}_SH01",
258
+ "scene_id": scene.id if scene else "S01",
259
+ "description": scene_desc[:80] if scene_desc else "Establishing shot",
260
+ "framing": "wide",
261
+ "duration": max(3.0, target * 0.3),
262
+ "camera_motion": "static",
263
+ "lens": "35mm anamorphic",
264
+ "lighting": bible.style.lighting or "neutral",
265
+ "narrative_function": NarrFunction.ESTABLISH.value,
266
+ "characters": [],
267
+ "location_id": "",
268
+ },
269
+ {
270
+ "id": f"{base_id}_SH02",
271
+ "scene_id": scene.id if scene else "S01",
272
+ "description": "Medium shot - action continues",
273
+ "framing": "medium",
274
+ "duration": max(3.0, target * 0.4),
275
+ "camera_motion": "dolly",
276
+ "lens": "50mm prime",
277
+ "lighting": bible.style.lighting or "neutral",
278
+ "narrative_function": NarrFunction.REVEAL.value,
279
+ "characters": [],
280
+ "location_id": "",
281
+ },
282
+ {
283
+ "id": f"{base_id}_SH03",
284
+ "scene_id": scene.id if scene else "S01",
285
+ "description": "Close-up - emotional beat",
286
+ "framing": "close-up",
287
+ "duration": max(3.0, target * 0.3),
288
+ "camera_motion": "static",
289
+ "lens": "85mm prime",
290
+ "lighting": bible.style.lighting or "neutral",
291
+ "narrative_function": NarrFunction.EMOTION.value,
292
+ "characters": [],
293
+ "location_id": "",
294
+ },
295
+ ]
296
+ return shots
297
+
298
+
299
+ class ArtStyleAgent(BaseAgent):
300
+ """Defines visual identity, palette, lighting, production design."""
301
+
302
+ name = "art_style"
303
+ identity = "Art / Style Agent"
304
+ capabilities = ["visual_identity", "palette", "lighting", "production_design"]
305
+ bible_permissions = {
306
+ "style": "write",
307
+ "locations": "read",
308
+ "story": "read",
309
+ }
310
+
311
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
312
+ existing_style = bible.style
313
+ style_override = task.context.get("style_override", {})
314
+ user_input = task.objective or ""
315
+
316
+ system_prompt = (
317
+ "You are a Cinematographer and Art Director defining the visual identity of a film.\n"
318
+ "Output a JSON object with these exact keys (all values are strings unless noted):\n"
319
+ "- visual_language: one-sentence description of the film's visual grammar\n"
320
+ "- cinematography: camera philosophy (movement, framing ethos, shot rhythm)\n"
321
+ "- lighting: signature lighting approach (palette, contrast ratio, source logic)\n"
322
+ "- color_palette: comma-separated hex colors or color names\n"
323
+ "- aspect_ratio: e.g. '2.39:1', '16:9', '4:3'\n"
324
+ "- lens_language: preferred lens choices and their narrative effect\n"
325
+ "- production_design: key art-direction pillars (sets, props, texture)\n"
326
+ "- recurring_motifs: 3-5 visual motifs that recur throughout the film\n"
327
+ "Be specific, cinematic, and actionable for a generation pipeline.\n"
328
+ "Return ONLY valid JSON, no markdown fences."
329
+ )
330
+
331
+ style_section = (
332
+ f"\nExisting style bible (override where needed):\n"
333
+ f" visual_language: {existing_style.visual_language or '(empty)'}\n"
334
+ f" cinematography: {existing_style.cinematography or '(empty)'}\n"
335
+ f" lighting: {existing_style.lighting or '(empty)'}\n"
336
+ f" color_palette: {existing_style.color_palette or '(empty)'}\n"
337
+ f" aspect_ratio: {existing_style.aspect_ratio or '(empty)'}\n"
338
+ f" lens_language: {existing_style.lens_language or '(empty)'}\n"
339
+ f" production_design: {existing_style.production_design or '(empty)'}\n"
340
+ f" recurring_motifs: {existing_style.recurring_motifs or '(empty)'}\n"
341
+ )
342
+ override_section = (
343
+ f"\nExplicit overrides from user context:\n{json.dumps(style_override, indent=2)}\n"
344
+ if style_override
345
+ else ""
346
+ )
347
+
348
+ user_prompt = (
349
+ f"Project: {bible.title}\n"
350
+ f"Logline: {bible.logline}\n"
351
+ f"Genre: {bible.genre} | Tone: {bible.tone}\n"
352
+ f"Target runtime: {bible.target_runtime}s\n"
353
+ f"User creative brief: {user_input}\n"
354
+ + style_section
355
+ + override_section
356
+ + "\nDefine the complete style bible now."
357
+ )
358
+
359
+ llm = get_llm(provider="agnes")
360
+ messages = [
361
+ LLMMessage(role="system", content=system_prompt),
362
+ LLMMessage(role="user", content=user_prompt),
363
+ ]
364
+
365
+ try:
366
+ result_json = await llm.chat_json(
367
+ messages,
368
+ schema={
369
+ "type": "object",
370
+ "properties": {
371
+ "visual_language": {"type": "string"},
372
+ "cinematography": {"type": "string"},
373
+ "lighting": {"type": "string"},
374
+ "color_palette": {"type": "array", "items": {"type": "string"}},
375
+ "aspect_ratio": {"type": "string"},
376
+ },
377
+ },
378
+ )
379
+
380
+ if isinstance(result_json, dict) and "_parse_error" not in result_json:
381
+ merged = self._merge_style(existing_style, result_json)
382
+ bible_delta = {"style": merged.model_dump()}
383
+ else:
384
+ merged = self._merge_style(existing_style, style_override)
385
+ bible_delta = {"style": merged.model_dump()}
386
+ except Exception as exc:
387
+ merged = self._merge_style(existing_style, style_override)
388
+ bible_delta = {"style": merged.model_dump()}
389
+ return AgentResult(
390
+ task_id=task.task_id,
391
+ status="completed",
392
+ result={
393
+ "message": f"Style bible complete (LLM error: {exc}; used fallback)",
394
+ "llm_error": str(exc),
395
+ },
396
+ bible_delta=bible_delta,
397
+ risks=[f"LLM unavailable during art-style planning: {exc}"],
398
+ )
399
+
400
+ return AgentResult(
401
+ task_id=task.task_id,
402
+ status="completed",
403
+ result={"message": "Style bible complete"},
404
+ bible_delta=bible_delta,
405
+ )
406
+
407
+ @staticmethod
408
+ def _merge_style(existing: Any, override: dict[str, Any]) -> Any:
409
+ """Deep-merge partial style dict into existing Style model."""
410
+ data = existing.model_dump() if hasattr(existing, "model_dump") else dict(existing)
411
+ for key, value in override.items():
412
+ if value not in (None, "", []) and key in data:
413
+ data[key] = value
414
+ return data
415
+
416
+
417
+ class GenerationAgent(BaseAgent):
418
+ """Converts approved shot specs into provider generation jobs."""
419
+
420
+ name = "generation"
421
+ identity = "Generation Agent"
422
+ capabilities = ["image_gen", "video_gen", "audio_gen", "prompt_compile", "provider_select"]
423
+ bible_permissions = {
424
+ "style": "read",
425
+ "characters": "read",
426
+ "shots": "read",
427
+ "assets": "write",
428
+ }
429
+
430
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
431
+ return AgentResult(
432
+ task_id=task.task_id,
433
+ status="completed",
434
+ result={"message": "Generation jobs submitted"},
435
+ )
436
+
437
+
438
+ class QAAgent(BaseAgent):
439
+ """Quality assurance — checks assets against bible constraints."""
440
+
441
+ name = "qa"
442
+ identity = "QA / Continuity Agent"
443
+ capabilities = [
444
+ "technical_qa",
445
+ "visual_qa",
446
+ "character_qa",
447
+ "continuity_qa",
448
+ "narrative_qa",
449
+ "reject",
450
+ "approve",
451
+ ]
452
+ bible_permissions = {
453
+ "shots": "read",
454
+ "characters": "read",
455
+ "style": "read",
456
+ "assets": "read",
457
+ }
458
+
459
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
460
+ return AgentResult(
461
+ task_id=task.task_id,
462
+ status="completed",
463
+ result={"message": "QA complete"},
464
+ )
465
+
466
+
467
+ class EditorialAgent(BaseAgent):
468
+ """Assembles approved shots into scenes and sequences."""
469
+
470
+ name = "editorial"
471
+ identity = "Editorial Agent"
472
+ capabilities = ["assemble", "timeline", "pacing", "transitions"]
473
+ bible_permissions = {
474
+ "shots": "read",
475
+ "scenes": "write",
476
+ "assets": "read",
477
+ }
478
+
479
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
480
+ return AgentResult(
481
+ task_id=task.task_id,
482
+ status="completed",
483
+ result={"message": "Editorial assembly complete"},
484
+ )
485
+
486
+
487
+ class SoundAgent(BaseAgent):
488
+ """Handles dialogue, ambience, score, and audio continuity."""
489
+
490
+ name = "sound"
491
+ identity = "Sound Agent"
492
+ capabilities = ["dialogue", "ambience", " Foley", "score", "mix"]
493
+ bible_permissions = {
494
+ "sound": "write",
495
+ "scenes": "read",
496
+ "shots": "read",
497
+ }
498
+
499
+ async def execute(self, task: AgentTask, bible: Bible) -> AgentResult:
500
+ return AgentResult(
501
+ task_id=task.task_id,
502
+ status="completed",
503
+ result={"message": "Sound design complete"},
504
+ )
505
+
506
+
507
+ # ─── Agent registry ──────────────────────────────────────────────────────────
508
+
509
+ _AGENT_REGISTRY: dict[str, type[BaseAgent]] = {
510
+ "producer": ProducerAgent,
511
+ "story": StoryAgent,
512
+ "character": CharacterAgent,
513
+ "cinematography": CinematographyAgent,
514
+ "art_style": ArtStyleAgent,
515
+ "generation": GenerationAgent,
516
+ "qa": QAAgent,
517
+ "editorial": EditorialAgent,
518
+ "sound": SoundAgent,
519
+ }
520
+
521
+
522
+ def get_agent(name: str) -> BaseAgent:
523
+ cls = _AGENT_REGISTRY.get(name)
524
+ if cls is None:
525
+ raise ValueError(f"Unknown agent: {name}. Available: {list(_AGENT_REGISTRY.keys())}")
526
+ return cls()
527
+
528
+
529
+ def list_agents() -> list[str]:
530
+ return list(_AGENT_REGISTRY.keys())