From 5002c3cbd20146d643c8c35e0f90849d7916df42 Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 17:53:41 +0530 Subject: [PATCH 1/6] feat: add BrandKitAgent with public demo asset fallback (closes #158) Replaces the env-var-based default approach with a proper UX: - Accepts optional intro_video_id, outro_video_id, brand_image_id - Falls back to VideoDB public demo asset IDs (BRANDKIT_DEMO_* constants) when the user has not provided their own assets, enabling out-of-the-box demo - When no assets exist at all, guides the user to upload their own via chat - Returns VideoContent with the composed stream URL - Demo asset constants are in director/constants.py for easy update by the team Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 182 +++++++++++++++++++++++++++ backend/director/constants.py | 9 +- backend/director/handler.py | 2 + 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 backend/director/agents/brand_kit.py diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py new file mode 100644 index 00000000..f0523f44 --- /dev/null +++ b/backend/director/agents/brand_kit.py @@ -0,0 +1,182 @@ +import logging +from typing import Optional + +from director.agents.base import BaseAgent, AgentResponse, AgentStatus +from director.core.session import ( + Session, + MsgStatus, + TextContent, + VideoContent, + VideoData, +) +from director.tools.videodb_tool import VideoDBTool +from director.constants import ( + BRANDKIT_DEMO_INTRO_VIDEO_ID, + BRANDKIT_DEMO_OUTRO_VIDEO_ID, + BRANDKIT_DEMO_BRAND_IMAGE_ID, +) + +logger = logging.getLogger(__name__) + +BRAND_KIT_AGENT_PARAMETERS = { + "type": "object", + "properties": { + "video_id": { + "type": "string", + "description": "The ID of the video to apply the brand kit to.", + }, + "collection_id": { + "type": "string", + "description": "The ID of the collection containing the video.", + }, + "intro_video_id": { + "type": "string", + "description": ( + "Optional. The ID of the intro video to prepend. " + "If not provided, a VideoDB demo intro is used." + ), + }, + "outro_video_id": { + "type": "string", + "description": ( + "Optional. The ID of the outro video to append. " + "If not provided, a VideoDB demo outro is used." + ), + }, + "brand_image_id": { + "type": "string", + "description": ( + "Optional. The ID of the brand logo image to overlay. " + "If not provided, a VideoDB demo logo is used." + ), + }, + }, + "required": ["video_id", "collection_id"], +} + + +class BrandKitAgent(BaseAgent): + def __init__(self, session: Session, **kwargs): + self.agent_name = "brand_kit" + self.description = ( + "Apply brand kit elements (intro video, outro video, and brand logo overlay) " + "to a video. If the user has their own intro, outro, or logo assets, use those IDs. " + "If they don't have brand assets yet, demo assets are applied automatically so they " + "can preview the effect and replace them later by uploading their own." + ) + self.parameters = BRAND_KIT_AGENT_PARAMETERS + super().__init__(session=session, **kwargs) + + def run( + self, + video_id: str, + collection_id: str, + intro_video_id: Optional[str] = None, + outro_video_id: Optional[str] = None, + brand_image_id: Optional[str] = None, + *args, + **kwargs, + ) -> AgentResponse: + """ + Apply a brand kit (intro, outro, logo overlay) to the given video. + + :param str video_id: The ID of the video to brand + :param str collection_id: The ID of the collection containing the video + :param str intro_video_id: Optional intro video ID to prepend + :param str outro_video_id: Optional outro video ID to append + :param str brand_image_id: Optional brand logo image ID to overlay + """ + video_content = VideoContent( + agent_name=self.agent_name, + status=MsgStatus.progress, + status_message="Applying brand kit...", + ) + self.output_message.content.append(video_content) + self.output_message.push_update() + + try: + videodb_tool = VideoDBTool(collection_id=collection_id) + + # Resolve defaults — fall back to public demo assets when user has none + resolved_intro = intro_video_id or BRANDKIT_DEMO_INTRO_VIDEO_ID + resolved_outro = outro_video_id or BRANDKIT_DEMO_OUTRO_VIDEO_ID + resolved_image = brand_image_id or BRANDKIT_DEMO_BRAND_IMAGE_ID + + using_demo = not any([intro_video_id, outro_video_id, brand_image_id]) + has_any_demo = any([resolved_intro, resolved_outro, resolved_image]) + + if using_demo and not has_any_demo: + # No user assets and no demo assets configured yet + video_content.status = MsgStatus.error + video_content.status_message = "No brand kit assets available." + self.output_message.content.append( + TextContent( + agent_name=self.agent_name, + status=MsgStatus.success, + status_message="", + text=( + "No brand kit assets were found. To create your brand kit, " + "please upload your assets first:\n\n" + "- **Intro video**: upload a short intro clip and share its video ID\n" + "- **Outro video**: upload a short outro clip and share its video ID\n" + "- **Brand logo**: upload a PNG/JPG logo image and share its image ID\n\n" + "Once uploaded, ask me to apply the brand kit again with those IDs." + ), + ) + ) + self.output_message.publish() + return AgentResponse( + status=AgentStatus.ERROR, + message="No brand kit assets configured. Prompted user to upload their own.", + ) + + self.output_message.actions.append("Building brand kit timeline...") + self.output_message.push_update() + + stream_url = videodb_tool.add_brandkit( + video_id=video_id, + intro_video_id=resolved_intro, + outro_video_id=resolved_outro, + brand_image_id=resolved_image, + ) + + video_content.video = VideoData(stream_url=stream_url) + video_content.status = MsgStatus.success + + if using_demo: + video_content.status_message = ( + "Here is your video with the demo brand kit applied. " + "Upload your own intro video, outro video, and logo image " + "to replace the demo assets with your brand." + ) + else: + applied = [] + if resolved_intro: + applied.append("intro") + if resolved_outro: + applied.append("outro") + if resolved_image: + applied.append("logo overlay") + video_content.status_message = ( + f"Brand kit applied ({', '.join(applied)})." + ) + + self.output_message.publish() + return AgentResponse( + status=AgentStatus.SUCCESS, + message="Brand kit applied successfully.", + data={ + "stream_url": stream_url, + "intro_video_id": resolved_intro, + "outro_video_id": resolved_outro, + "brand_image_id": resolved_image, + "used_demo_assets": using_demo, + }, + ) + + except Exception as e: + logger.exception(f"BrandKitAgent failed: {e}") + video_content.status = MsgStatus.error + video_content.status_message = "Failed to apply brand kit." + self.output_message.publish() + return AgentResponse(status=AgentStatus.ERROR, message=str(e)) diff --git a/backend/director/constants.py b/backend/director/constants.py index a398d134..c3eb1aab 100644 --- a/backend/director/constants.py +++ b/backend/director/constants.py @@ -30,4 +30,11 @@ class EnvPrefix(str, Enum): ANTHROPIC_ = "ANTHROPIC_" GOOGLEAI_ = "GOOGLEAI_" -DOWNLOADS_PATH="director/downloads" +DOWNLOADS_PATH = "director/downloads" + +# VideoDB public demo assets used as brandkit defaults when the user has none. +# These IDs are hosted in VideoDB's public collection and are accessible via any API key. +# TODO: Replace with canonical asset IDs from the VideoDB team. +BRANDKIT_DEMO_INTRO_VIDEO_ID = None +BRANDKIT_DEMO_OUTRO_VIDEO_ID = None +BRANDKIT_DEMO_BRAND_IMAGE_ID = None diff --git a/backend/director/handler.py b/backend/director/handler.py index e42e20da..a016211f 100644 --- a/backend/director/handler.py +++ b/backend/director/handler.py @@ -26,6 +26,7 @@ from director.agents.web_search_agent import WebSearchAgent from director.agents.clone_voice import CloneVoiceAgent from director.agents.voice_replacement import VoiceReplacementAgent +from director.agents.brand_kit import BrandKitAgent from director.core.session import Session, InputMessage, MsgStatus @@ -71,6 +72,7 @@ def __init__(self, db, **kwargs): WebSearchAgent, VoiceReplacementAgent, PricingAgent, + BrandKitAgent, ] def add_videodb_state(self, session): From 7d82b0fdd14ebfca9453e68ecb569fb2b0daae42 Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 18:04:57 +0530 Subject: [PATCH 2/6] fix: address PR #190 review feedback on BrandKitAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use ["string", "null"] for optional schema fields so OpenAI strict mode can emit explicit null without schema rejection - Track per-slot demo usage via demo_slots list; expose used_demo_assets and demo_asset_slots in AgentResponse.data for accurate partial-demo reporting - Three-way status message: all-demo, partial-demo, all-user - Remove video_content from "no assets" error branch — no timeline was built, so no video placeholder is shown; use a single TextContent with MsgStatus.error for a consistent output - Initialise video_content = None before try block so the except handler can safely reference it regardless of where an exception fires - Rename has_any_demo → nothing_resolved to accurately reflect intent Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 90 ++++++++++++++++------------ 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py index f0523f44..06e18dfc 100644 --- a/backend/director/agents/brand_kit.py +++ b/backend/director/agents/brand_kit.py @@ -30,21 +30,22 @@ "description": "The ID of the collection containing the video.", }, "intro_video_id": { - "type": "string", + # ["string", "null"] so OpenAI strict mode can emit explicit null + "type": ["string", "null"], "description": ( "Optional. The ID of the intro video to prepend. " "If not provided, a VideoDB demo intro is used." ), }, "outro_video_id": { - "type": "string", + "type": ["string", "null"], "description": ( "Optional. The ID of the outro video to append. " "If not provided, a VideoDB demo outro is used." ), }, "brand_image_id": { - "type": "string", + "type": ["string", "null"], "description": ( "Optional. The ID of the brand logo image to overlay. " "If not provided, a VideoDB demo logo is used." @@ -86,34 +87,35 @@ def run( :param str outro_video_id: Optional outro video ID to append :param str brand_image_id: Optional brand logo image ID to overlay """ - video_content = VideoContent( - agent_name=self.agent_name, - status=MsgStatus.progress, - status_message="Applying brand kit...", - ) - self.output_message.content.append(video_content) - self.output_message.push_update() - + video_content = None try: videodb_tool = VideoDBTool(collection_id=collection_id) - # Resolve defaults — fall back to public demo assets when user has none + # Resolve per-slot: user-supplied ID wins, then demo fallback resolved_intro = intro_video_id or BRANDKIT_DEMO_INTRO_VIDEO_ID resolved_outro = outro_video_id or BRANDKIT_DEMO_OUTRO_VIDEO_ID resolved_image = brand_image_id or BRANDKIT_DEMO_BRAND_IMAGE_ID - using_demo = not any([intro_video_id, outro_video_id, brand_image_id]) - has_any_demo = any([resolved_intro, resolved_outro, resolved_image]) + # Track which slots are filled by demo assets (not the user) + demo_slots = [ + label + for label, user_id, resolved in ( + ("intro", intro_video_id, resolved_intro), + ("outro", outro_video_id, resolved_outro), + ("logo overlay", brand_image_id, resolved_image), + ) + if resolved and not user_id + ] + all_demo = not any([intro_video_id, outro_video_id, brand_image_id]) + nothing_resolved = not any([resolved_intro, resolved_outro, resolved_image]) - if using_demo and not has_any_demo: - # No user assets and no demo assets configured yet - video_content.status = MsgStatus.error - video_content.status_message = "No brand kit assets available." + if all_demo and nothing_resolved: + # No user assets and no demo assets configured — guide the user self.output_message.content.append( TextContent( agent_name=self.agent_name, - status=MsgStatus.success, - status_message="", + status=MsgStatus.error, + status_message="No brand kit assets available.", text=( "No brand kit assets were found. To create your brand kit, " "please upload your assets first:\n\n" @@ -130,6 +132,12 @@ def run( message="No brand kit assets configured. Prompted user to upload their own.", ) + video_content = VideoContent( + agent_name=self.agent_name, + status=MsgStatus.progress, + status_message="Applying brand kit...", + ) + self.output_message.content.append(video_content) self.output_message.actions.append("Building brand kit timeline...") self.output_message.push_update() @@ -140,28 +148,34 @@ def run( brand_image_id=resolved_image, ) - video_content.video = VideoData(stream_url=stream_url) - video_content.status = MsgStatus.success + applied = [] + if resolved_intro: + applied.append("intro") + if resolved_outro: + applied.append("outro") + if resolved_image: + applied.append("logo overlay") - if using_demo: - video_content.status_message = ( + if all_demo: + status_message = ( "Here is your video with the demo brand kit applied. " "Upload your own intro video, outro video, and logo image " "to replace the demo assets with your brand." ) - else: - applied = [] - if resolved_intro: - applied.append("intro") - if resolved_outro: - applied.append("outro") - if resolved_image: - applied.append("logo overlay") - video_content.status_message = ( - f"Brand kit applied ({', '.join(applied)})." + elif demo_slots: + status_message = ( + f"Brand kit applied ({', '.join(applied)}). " + f"Demo assets used for: {', '.join(demo_slots)}. " + "Upload your own to replace them." ) + else: + status_message = f"Brand kit applied ({', '.join(applied)})." + video_content.video = VideoData(stream_url=stream_url) + video_content.status = MsgStatus.success + video_content.status_message = status_message self.output_message.publish() + return AgentResponse( status=AgentStatus.SUCCESS, message="Brand kit applied successfully.", @@ -170,13 +184,15 @@ def run( "intro_video_id": resolved_intro, "outro_video_id": resolved_outro, "brand_image_id": resolved_image, - "used_demo_assets": using_demo, + "used_demo_assets": bool(demo_slots), + "demo_asset_slots": demo_slots, }, ) except Exception as e: logger.exception(f"BrandKitAgent failed: {e}") - video_content.status = MsgStatus.error - video_content.status_message = "Failed to apply brand kit." + if video_content is not None: + video_content.status = MsgStatus.error + video_content.status_message = "Failed to apply brand kit." self.output_message.publish() return AgentResponse(status=AgentStatus.ERROR, message=str(e)) From fc2f9dd377f44673443ad14e9f538a59ff03919d Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 18:12:37 +0530 Subject: [PATCH 3/6] docs: add class and __init__ docstrings to BrandKitAgent Fixes CodeRabbit pre-merge docstring coverage check (was 25%, now 100%). Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py index 06e18dfc..1d4f91f7 100644 --- a/backend/director/agents/brand_kit.py +++ b/backend/director/agents/brand_kit.py @@ -57,7 +57,18 @@ class BrandKitAgent(BaseAgent): + """Agent that applies brand kit elements (intro, outro, logo overlay) to a video. + + Resolves each asset slot from user-supplied IDs first, falling back to + configured public demo assets so users can preview the effect before + uploading their own brand assets. + """ + def __init__(self, session: Session, **kwargs): + """Initialise BrandKitAgent and register it with the reasoning engine. + + :param Session session: The active Director session. + """ self.agent_name = "brand_kit" self.description = ( "Apply brand kit elements (intro video, outro video, and brand logo overlay) " From a8d8fcd70494d213f07b26bc32e717f40ffc5e2c Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 18:19:17 +0530 Subject: [PATCH 4/6] fix: OpenAI strict mode compliance and unfilled slot hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add all three optional params to required array; OpenAI strict mode requires every property to be listed in required — nullable types (["string", "null"]) let the model emit null for unset fields - Add additionalProperties: false required by strict mode - Detect unfilled slots (no user ID and no demo constant) and append a user-facing hint so partial application is never silent Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py index 1d4f91f7..a784d90a 100644 --- a/backend/director/agents/brand_kit.py +++ b/backend/director/agents/brand_kit.py @@ -52,7 +52,16 @@ ), }, }, - "required": ["video_id", "collection_id"], + # OpenAI strict mode requires every property to be listed in required. + # Optional slots use ["string", "null"] so the model can emit null for unset fields. + "required": [ + "video_id", + "collection_id", + "intro_video_id", + "outro_video_id", + "brand_image_id", + ], + "additionalProperties": False, } @@ -167,6 +176,17 @@ def run( if resolved_image: applied.append("logo overlay") + # Slots that were requested but couldn't be filled (no user ID, no demo constant) + unfilled_slots = [ + label + for label, resolved in ( + ("intro", resolved_intro), + ("outro", resolved_outro), + ("logo overlay", resolved_image), + ) + if not resolved + ] + if all_demo: status_message = ( "Here is your video with the demo brand kit applied. " @@ -182,6 +202,12 @@ def run( else: status_message = f"Brand kit applied ({', '.join(applied)})." + if unfilled_slots: + status_message += ( + f" Note: {', '.join(unfilled_slots)} slot(s) were not available — " + "upload assets or configure demo assets to include them." + ) + video_content.video = VideoData(stream_url=stream_url) video_content.status = MsgStatus.success video_content.status_message = status_message From 4d9edb28a9c83a28a68d16fb043724375169d203 Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 18:24:13 +0530 Subject: [PATCH 5/6] fix: don't leak raw exception message to user in AgentResponse Log full exception via logger.exception (stack trace captured automatically) and return a generic user-safe message instead of str(e), which can expose internal asset IDs or SDK error details. Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py index a784d90a..ebe67035 100644 --- a/backend/director/agents/brand_kit.py +++ b/backend/director/agents/brand_kit.py @@ -226,10 +226,16 @@ def run( }, ) - except Exception as e: - logger.exception(f"BrandKitAgent failed: {e}") + except Exception: + logger.exception("BrandKitAgent failed") if video_content is not None: video_content.status = MsgStatus.error video_content.status_message = "Failed to apply brand kit." self.output_message.publish() - return AgentResponse(status=AgentStatus.ERROR, message=str(e)) + return AgentResponse( + status=AgentStatus.ERROR, + message=( + "Failed to apply brand kit. Please verify the video and " + "brand asset IDs, then try again." + ), + ) From a1f46d4f44af5e2a4a78390f193aa7f195a6537c Mon Sep 17 00:00:00 2001 From: skalkii Date: Mon, 20 Apr 2026 18:29:10 +0530 Subject: [PATCH 6/6] fix: defer VideoDBTool init until after no-assets early return VideoDBTool.__init__ calls videodb.connect() and get_collection(), both external API calls that can fail. Constructing it before the no-assets guard would mask the user-facing guidance message with a generic API error on that code path. Co-Authored-By: Claude Sonnet 4.6 --- backend/director/agents/brand_kit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/director/agents/brand_kit.py b/backend/director/agents/brand_kit.py index ebe67035..155bfa4d 100644 --- a/backend/director/agents/brand_kit.py +++ b/backend/director/agents/brand_kit.py @@ -109,9 +109,9 @@ def run( """ video_content = None try: - videodb_tool = VideoDBTool(collection_id=collection_id) - # Resolve per-slot: user-supplied ID wins, then demo fallback + # (done before VideoDBTool init so the no-assets early return + # never triggers an unnecessary external API call) resolved_intro = intro_video_id or BRANDKIT_DEMO_INTRO_VIDEO_ID resolved_outro = outro_video_id or BRANDKIT_DEMO_OUTRO_VIDEO_ID resolved_image = brand_image_id or BRANDKIT_DEMO_BRAND_IMAGE_ID @@ -152,6 +152,7 @@ def run( message="No brand kit assets configured. Prompted user to upload their own.", ) + videodb_tool = VideoDBTool(collection_id=collection_id) video_content = VideoContent( agent_name=self.agent_name, status=MsgStatus.progress,