From 07e044b0ead51ed17f70f74412e069a2cc9f2418 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 7 Nov 2025 07:17:32 -0500 Subject: [PATCH 1/3] Implement batch preview and HTML output support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add batch mode (1-50 creatives in one call) and HTML output format to preview_creative tool, implementing the new ADCP spec from PR #183. Batch mode enables 5-10x faster preview generation for format showcases and campaign review grids. HTML output mode eliminates storage overhead by returning preview_html directly instead of uploading to S3. Maintains full backward compatibility with existing URL-based preview mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ..._creative_preview_creative_request_json.py | 103 +++- ...creative_preview_creative_response_json.py | 266 ++++++++++- src/creative_agent/server.py | 443 ++++++++++++------ tests/integration/test_preview_creative.py | 9 +- .../test_preview_html_and_batch.py | 316 +++++++++++++ .../integration/test_tool_response_formats.py | 14 +- ...reative_preview-creative-request_json.json | 177 +++++-- ...eative_preview-creative-response_json.json | 398 +++++++++++----- 8 files changed, 1401 insertions(+), 325 deletions(-) create mode 100644 tests/integration/test_preview_html_and_batch.py diff --git a/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_request_json.py b/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_request_json.py index e4e187d..16ec3d0 100644 --- a/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_request_json.py +++ b/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_request_json.py @@ -6,7 +6,15 @@ from enum import Enum from typing import Annotated, Any, Optional, Union -from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, EmailStr, Field +from pydantic import ( + AnyUrl, + AwareDatetime, + BaseModel, + ConfigDict, + EmailStr, + Field, + RootModel, +) class FormatId(BaseModel): @@ -863,7 +871,12 @@ class Input(BaseModel): ] = None -class PreviewCreativeRequest(BaseModel): +class OutputFormat(Enum): + url = "url" + html = "html" + + +class PreviewCreativeRequest1(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -891,3 +904,89 @@ class PreviewCreativeRequest(BaseModel): Optional[str], Field(description="Specific template ID for custom format rendering"), ] = None + output_format: Annotated[ + Optional[OutputFormat], + Field( + description="Output format for previews. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML for direct embedding). Default: 'url' for backward compatibility." + ), + ] = "url" + + +class Input2(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + name: Annotated[str, Field(description="Human-readable name for this input set")] + macros: Annotated[ + Optional[dict[str, str]], + Field(description="Macro values to use for this preview"), + ] = None + context_description: Annotated[ + Optional[str], + Field( + description="Natural language description of the context for AI-generated content" + ), + ] = None + + +class Request(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + format_id: Annotated[ + Any, Field(description="Circular reference to /schemas/v1/core/format-id.json") + ] + creative_manifest: Annotated[ + Any, + Field( + description="Circular reference to /schemas/v1/core/creative-manifest.json" + ), + ] + inputs: Annotated[ + Optional[list[Input2]], + Field( + description="Array of input sets for generating multiple preview variants" + ), + ] = None + template_id: Annotated[ + Optional[str], + Field(description="Specific template ID for custom format rendering"), + ] = None + output_format: Annotated[ + Optional[OutputFormat], + Field( + description="Output format for this preview. 'url' returns preview_url, 'html' returns preview_html." + ), + ] = "url" + + +class PreviewCreativeRequest2(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + requests: Annotated[ + list[Request], + Field( + description="Array of preview requests (1-50 items). Each follows the single request structure.", + max_length=50, + min_length=1, + ), + ] + output_format: Annotated[ + Optional[OutputFormat], + Field( + description="Default output format for all requests in this batch. Individual requests can override this. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML for direct embedding)." + ), + ] = "url" + + +class PreviewCreativeRequest( + RootModel[Union[PreviewCreativeRequest1, PreviewCreativeRequest2]] +): + root: Annotated[ + Union[PreviewCreativeRequest1, PreviewCreativeRequest2], + Field( + description="Request to generate previews of one or more creative manifests. Accepts either a single creative request or an array of requests for batch processing.", + title="Preview Creative Request", + ), + ] diff --git a/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_response_json.py b/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_response_json.py index d0341b0..7c95e7f 100644 --- a/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_response_json.py +++ b/src/creative_agent/schemas_generated/_schemas_v1_creative_preview_creative_response_json.py @@ -3,9 +3,9 @@ from __future__ import annotations -from typing import Annotated, Optional +from typing import Annotated, Any, Literal, Optional, Union -from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field, RootModel class Dimensions(BaseModel): @@ -34,7 +34,7 @@ class Embedding(BaseModel): ] = None -class Render(BaseModel): +class Renders(BaseModel): render_id: Annotated[ str, Field( @@ -44,7 +44,91 @@ class Render(BaseModel): preview_url: Annotated[ AnyUrl, Field( - description="URL to an HTML page that renders this piece. Can be embedded in an iframe. Handles all rendering complexity internally (images, video players, audio players, interactive content, etc.)." + description="URL to an HTML page that renders this piece. Can be embedded in an iframe. Typically returned when output_format='url' (default). Creative agents MAY provide both preview_url and preview_html for client flexibility." + ), + ] + preview_html: Annotated[ + Optional[str], + Field( + description="Raw HTML for this rendered piece. Can be embedded directly in the page without iframe. Typically returned when output_format='html'. Security warning: Only use with trusted creative agents as this bypasses iframe sandboxing. Creative agents MAY provide both formats." + ), + ] = None + role: Annotated[ + str, + Field( + description="Semantic role of this rendered piece. Use 'primary' for main content, 'companion' for associated banners, descriptive strings for device variants or custom roles." + ), + ] + dimensions: Annotated[ + Optional[Dimensions], + Field( + description="Dimensions for this rendered piece. For companion ads with multiple sizes, this specifies which size this piece is." + ), + ] = None + embedding: Annotated[ + Optional[Embedding], + Field( + description="Optional security and embedding metadata for safe iframe integration" + ), + ] = None + + +class Renders1(BaseModel): + render_id: Annotated[ + str, + Field( + description="Unique identifier for this rendered piece within the variant" + ), + ] + preview_url: Annotated[ + Optional[AnyUrl], + Field( + description="URL to an HTML page that renders this piece. Can be embedded in an iframe. Typically returned when output_format='url' (default). Creative agents MAY provide both preview_url and preview_html for client flexibility." + ), + ] = None + preview_html: Annotated[ + str, + Field( + description="Raw HTML for this rendered piece. Can be embedded directly in the page without iframe. Typically returned when output_format='html'. Security warning: Only use with trusted creative agents as this bypasses iframe sandboxing. Creative agents MAY provide both formats." + ), + ] + role: Annotated[ + str, + Field( + description="Semantic role of this rendered piece. Use 'primary' for main content, 'companion' for associated banners, descriptive strings for device variants or custom roles." + ), + ] + dimensions: Annotated[ + Optional[Dimensions], + Field( + description="Dimensions for this rendered piece. For companion ads with multiple sizes, this specifies which size this piece is." + ), + ] = None + embedding: Annotated[ + Optional[Embedding], + Field( + description="Optional security and embedding metadata for safe iframe integration" + ), + ] = None + + +class Renders2(BaseModel): + render_id: Annotated[ + str, + Field( + description="Unique identifier for this rendered piece within the variant" + ), + ] + preview_url: Annotated[ + AnyUrl, + Field( + description="URL to an HTML page that renders this piece. Can be embedded in an iframe. Typically returned when output_format='url' (default). Creative agents MAY provide both preview_url and preview_html for client flexibility." + ), + ] + preview_html: Annotated[ + str, + Field( + description="Raw HTML for this rendered piece. Can be embedded directly in the page without iframe. Typically returned when output_format='html'. Security warning: Only use with trusted creative agents as this bypasses iframe sandboxing. Creative agents MAY provide both formats." ), ] role: Annotated[ @@ -83,7 +167,7 @@ class Preview(BaseModel): str, Field(description="Unique identifier for this preview variant") ] renders: Annotated[ - list[Render], + list[Union[Renders, Renders1, Renders2]], Field( description="Array of rendered pieces for this preview variant. Most formats render as a single piece. Companion ad formats (video + banner), multi-placement formats, and adaptive formats render as multiple pieces.", min_length=1, @@ -97,7 +181,7 @@ class Preview(BaseModel): ] -class PreviewCreativeResponse(BaseModel): +class PreviewCreativeResponse1(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -117,3 +201,173 @@ class PreviewCreativeResponse(BaseModel): expires_at: Annotated[ AwareDatetime, Field(description="ISO 8601 timestamp when preview links expire") ] + + +class Embedding3(BaseModel): + recommended_sandbox: Optional[str] = None + requires_https: Optional[bool] = None + supports_fullscreen: Optional[bool] = None + csp_policy: Optional[str] = None + + +class Renders3(BaseModel): + render_id: str + preview_url: Annotated[ + AnyUrl, + Field( + description="URL to iframe-embeddable HTML page. Typically present when output_format='url'." + ), + ] + preview_html: Annotated[ + Optional[str], + Field( + description="Raw HTML for direct embedding. Typically present when output_format='html'. Security: Only use with trusted agents." + ), + ] = None + role: str + dimensions: Optional[Dimensions] = None + embedding: Optional[Embedding3] = None + + +class Renders4(BaseModel): + render_id: str + preview_url: Annotated[ + Optional[AnyUrl], + Field( + description="URL to iframe-embeddable HTML page. Typically present when output_format='url'." + ), + ] = None + preview_html: Annotated[ + str, + Field( + description="Raw HTML for direct embedding. Typically present when output_format='html'. Security: Only use with trusted agents." + ), + ] + role: str + dimensions: Optional[Dimensions] = None + embedding: Optional[Embedding3] = None + + +class Renders5(BaseModel): + render_id: str + preview_url: Annotated[ + AnyUrl, + Field( + description="URL to iframe-embeddable HTML page. Typically present when output_format='url'." + ), + ] + preview_html: Annotated[ + str, + Field( + description="Raw HTML for direct embedding. Typically present when output_format='html'. Security: Only use with trusted agents." + ), + ] + role: str + dimensions: Optional[Dimensions] = None + embedding: Optional[Embedding3] = None + + +class Input4(BaseModel): + name: str + macros: Optional[dict[str, str]] = None + context_description: Optional[str] = None + + +class Preview1(BaseModel): + preview_id: str + renders: Annotated[list[Union[Renders3, Renders4, Renders5]], Field(min_length=1)] + input: Input4 + + +class Response(BaseModel): + previews: Annotated[ + list[Preview1], + Field(description="Array of preview variants for this creative", min_length=1), + ] + interactive_url: Optional[AnyUrl] = None + expires_at: AwareDatetime + + +class Error(BaseModel): + code: Annotated[ + str, + Field( + description="Error code (e.g., 'invalid_manifest', 'unsupported_format', 'missing_assets')" + ), + ] + message: Annotated[str, Field(description="Human-readable error message")] + details: Annotated[ + Optional[dict[str, Any]], Field(description="Additional error context") + ] = None + + +class Results(BaseModel): + success: Annotated[ + Literal[True], Field(description="Whether this preview request succeeded") + ] + response: Annotated[ + Response, Field(description="Preview response for successful requests") + ] + error: Annotated[ + Optional[Error], Field(description="Error information for failed requests") + ] = None + + +Renders6 = Renders3 + + +Renders7 = Renders4 + + +Renders8 = Renders5 + + +class Preview2(BaseModel): + preview_id: str + renders: Annotated[list[Union[Renders6, Renders7, Renders8]], Field(min_length=1)] + input: Input4 + + +class Response1(BaseModel): + previews: Annotated[ + list[Preview2], + Field(description="Array of preview variants for this creative", min_length=1), + ] + interactive_url: Optional[AnyUrl] = None + expires_at: AwareDatetime + + +class Results1(BaseModel): + success: Annotated[ + Literal[False], Field(description="Whether this preview request succeeded") + ] + response: Annotated[ + Optional[Response1], + Field(description="Preview response for successful requests"), + ] = None + error: Annotated[Error, Field(description="Error information for failed requests")] + + +class PreviewCreativeResponse2(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + results: Annotated[ + list[Union[Results, Results1]], + Field( + description="Array of preview results corresponding to each request in the same order. results[0] is the result for requests[0], results[1] for requests[1], etc. Order is guaranteed even when some requests fail. Each result contains either a successful preview response or an error.", + min_length=1, + ), + ] + + +class PreviewCreativeResponse( + RootModel[Union[PreviewCreativeResponse1, PreviewCreativeResponse2]] +): + root: Annotated[ + Union[PreviewCreativeResponse1, PreviewCreativeResponse2], + Field( + description="Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests.", + title="Preview Creative Response", + ), + ] diff --git a/src/creative_agent/server.py b/src/creative_agent/server.py index 2c58804..d723687 100644 --- a/src/creative_agent/server.py +++ b/src/creative_agent/server.py @@ -22,9 +22,6 @@ PreviewCreativeRequest, ) from .schemas_generated._schemas_v1_core_format_json import FormatId -from .schemas_generated._schemas_v1_creative_preview_creative_response_json import ( - PreviewCreativeResponse, -) mcp = FastMCP("adcp-creative-agent") @@ -168,184 +165,317 @@ def list_creative_formats( @mcp.tool() def preview_creative( - format_id: str | dict[str, Any], - creative_manifest: dict[str, Any], + format_id: str | dict[str, Any] | None = None, + creative_manifest: dict[str, Any] | None = None, inputs: list[dict[str, Any]] | None = None, template_id: str | None = None, + output_format: str = "url", + requests: list[dict[str, Any]] | None = None, ) -> ToolResult: - """Generate preview renderings of a creative manifest. + """Generate preview renderings of one or more creative manifests. + + Supports two modes: + 1. Single mode: Preview one creative with format_id and creative_manifest + 2. Batch mode: Preview multiple creatives with requests array (5-10x faster) Args: - format_id: Format identifier for rendering (string or FormatId object with agent_url and id) - creative_manifest: Complete creative manifest with all required assets (including promoted_offerings if required by the format) - inputs: Array of input sets for generating multiple preview variants - template_id: Specific template for custom format rendering + format_id: Format identifier for rendering (single mode only) + creative_manifest: Complete creative manifest (single mode only) + inputs: Array of input sets for generating multiple preview variants (single mode) + template_id: Specific template for custom format rendering (single mode) + output_format: Output format - "url" (default) returns preview_url, "html" returns preview_html + requests: Array of 1-50 preview requests for batch mode (each with format_id, creative_manifest, etc.) Returns: ToolResult with human-readable message and structured preview data """ try: - # Import schema types - from .schemas.manifest import PreviewInput - - # Parse inputs if provided - inputs_obj: list[PreviewInput] | None = None - if inputs: - inputs_obj = [PreviewInput(**inp) for inp in inputs] - - # Parse request (creative_manifest stays as dict) - # Handle format_id as string or FormatId object (dict) - if isinstance(format_id, str): - fmt_id = FormatId(agent_url=AGENT_URL, id=format_id) - else: # dict - fmt_id = FormatId(**format_id) - request = PreviewCreativeRequest( - format_id=fmt_id, - creative_manifest=creative_manifest, - inputs=inputs_obj, - template_id=template_id, - ) - - # Validate format exists - fmt = get_format_by_id(request.format_id) - if not fmt: - error_msg = f"Format {request.format_id} not found" + # Determine mode: batch or single + is_batch_mode = requests is not None + + if is_batch_mode: + # Batch mode: process multiple preview requests + return _handle_batch_preview(requests or [], output_format) + # Single mode: process single preview request + if format_id is None or creative_manifest is None: + error_msg = "Either provide (format_id + creative_manifest) for single mode, or (requests) for batch mode" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], structured_content={"error": error_msg}, ) + return _handle_single_preview( + format_id=format_id, + creative_manifest=creative_manifest, + inputs=inputs, + template_id=template_id, + output_format=output_format, + ) + except ValueError as e: + error_msg = f"Invalid input: {e}" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg}, + ) + except Exception as e: + import traceback - # Validate manifest format_id matches - manifest_format_id = request.creative_manifest.get("format_id") - if manifest_format_id: - # Normalize both for comparison - manifest_norm = normalize_format_id_for_comparison(manifest_format_id) - request_norm = normalize_format_id_for_comparison(request.format_id) - if manifest_norm != request_norm: - error_msg = ( - f"Manifest format_id (id='{manifest_norm[0]}', agent_url='{manifest_norm[1]}') " - f"does not match request format_id (id='{request_norm[0]}', agent_url='{request_norm[1]}')" - ) - return ToolResult( - content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg}, - ) + error_msg = f"Preview generation failed: {e}" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg, "traceback": traceback.format_exc()[-500:]}, + ) + + +def _handle_single_preview( + format_id: str | dict[str, Any], + creative_manifest: dict[str, Any], + inputs: list[dict[str, Any]] | None, + template_id: str | None, + output_format: str, +) -> ToolResult: + """Handle a single preview request.""" + from .schemas.manifest import PreviewInput - # Validate manifest assets - from .validation import validate_manifest_assets + # Parse inputs if provided + inputs_obj: list[PreviewInput] | None = None + if inputs: + inputs_obj = [PreviewInput(**inp) for inp in inputs] - validation_errors = validate_manifest_assets( - request.creative_manifest, - check_remote_mime=False, - format_obj=fmt, + # Handle format_id as string or FormatId object (dict) + if isinstance(format_id, str): + fmt_id = FormatId(agent_url=AGENT_URL, id=format_id) + else: # dict + fmt_id = FormatId(**format_id) + + request = PreviewCreativeRequest( + format_id=fmt_id, + creative_manifest=creative_manifest, + inputs=inputs_obj, + template_id=template_id, + ) + + # Validate format exists + fmt = get_format_by_id(request.format_id) + if not fmt: + error_msg = f"Format {request.format_id} not found" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg}, ) - if validation_errors: - error_msg = "Asset validation failed" + + # Validate manifest format_id matches + manifest_format_id = request.creative_manifest.get("format_id") + if manifest_format_id: + manifest_norm = normalize_format_id_for_comparison(manifest_format_id) + request_norm = normalize_format_id_for_comparison(request.format_id) + if manifest_norm != request_norm: + error_msg = ( + f"Manifest format_id (id='{manifest_norm[0]}', agent_url='{manifest_norm[1]}') " + f"does not match request format_id (id='{request_norm[0]}', agent_url='{request_norm[1]}')" + ) return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg, "validation_errors": validation_errors}, + structured_content={"error": error_msg}, ) - # Generate preview variants - previews = [] - preview_id = str(uuid.uuid4()) + # Validate manifest assets + from .validation import validate_manifest_assets - # If no inputs provided, generate default variants (desktop, mobile, tablet) - if not request.inputs: - request.inputs = [ - PreviewInput(name="Desktop", macros={"DEVICE_TYPE": "desktop"}), - PreviewInput(name="Mobile", macros={"DEVICE_TYPE": "mobile"}), - PreviewInput(name="Tablet", macros={"DEVICE_TYPE": "tablet"}), - ] + validation_errors = validate_manifest_assets( + request.creative_manifest, + check_remote_mime=False, + format_obj=fmt, + ) + if validation_errors: + error_msg = "Asset validation failed" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg, "validation_errors": validation_errors}, + ) - # Generate a preview for each input set - from .storage import generate_preview_html, upload_preview_html + # Generate preview variants + preview_id = str(uuid.uuid4()) - for input_set in request.inputs: - # Generate HTML content - html_content = generate_preview_html(fmt, request.creative_manifest, input_set) + # If no inputs provided, generate default variants + if not request.inputs: + request.inputs = [ + PreviewInput(name="Desktop", macros={"DEVICE_TYPE": "desktop"}), + PreviewInput(name="Mobile", macros={"DEVICE_TYPE": "mobile"}), + PreviewInput(name="Tablet", macros={"DEVICE_TYPE": "tablet"}), + ] - # Upload to Tigris and get public URL - variant_name = input_set.name.lower().replace(" ", "-") - preview_url = upload_preview_html(preview_id, variant_name, html_content) + # Generate previews for each input set + from .storage import generate_preview_html, upload_preview_html + + previews = [] + for input_set in request.inputs: + html_content = generate_preview_html(fmt, request.creative_manifest, input_set) + variant_name = input_set.name.lower().replace(" ", "-") - # Create preview variant with actual URL + if output_format == "html": + # Return HTML directly without uploading + preview = _generate_preview_variant( + format_obj=fmt, + manifest=request.creative_manifest, + input_set=input_set, + preview_id=preview_id, + preview_url=None, + preview_html=html_content, + ) + else: + # Upload to Tigris and return URL + preview_url = upload_preview_html(preview_id, variant_name, html_content) preview = _generate_preview_variant( format_obj=fmt, manifest=request.creative_manifest, input_set=input_set, preview_id=preview_id, preview_url=preview_url, + preview_html=None, ) - previews.append(preview) + previews.append(preview) - # Calculate expiration (24 hours from now) - expires_at = datetime.now(UTC) + timedelta(hours=24) + # Calculate expiration + expires_at = datetime.now(UTC) + timedelta(hours=24) - from pydantic import AnyUrl, ValidationError + from pydantic import AnyUrl, ValidationError - from .schemas_generated._schemas_v1_creative_preview_creative_response_json import ( - Preview, - ) + from .schemas_generated._schemas_v1_creative_preview_creative_response_json import ( + Preview, + ) - # Validate previews with detailed error reporting - try: - validated_previews = [] - for idx, preview_dict in enumerate(previews): - try: - validated_previews.append(Preview.model_validate(preview_dict)) - except ValidationError as e: - error_msg = f"Preview validation failed for variant {idx + 1}" - return ToolResult( - content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg, "validation_errors": e.errors()}, - ) - - interactive_url = AnyUrl(f"{AGENT_URL}/preview/{preview_id}/interactive") - except ValidationError as e: - error_msg = f"Invalid URL construction: {e}" - return ToolResult( - content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg}, - ) + # Validate previews + try: + validated_previews = [] + for idx, preview_dict in enumerate(previews): + try: + validated_previews.append(Preview.model_validate(preview_dict)) + except ValidationError as e: + error_msg = f"Preview validation failed for variant {idx + 1}" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg, "validation_errors": e.errors()}, + ) - response = PreviewCreativeResponse( - previews=validated_previews, - interactive_url=interactive_url, - expires_at=expires_at, + interactive_url = AnyUrl(f"{AGENT_URL}/preview/{preview_id}/interactive") + except ValidationError as e: + error_msg = f"Invalid URL construction: {e}" + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg}, ) - # Return ToolResult with human message and structured data - preview_count = len(validated_previews) - format_id_str = fmt_id.id if hasattr(fmt_id, "id") else str(fmt_id) - message = f"Generated {preview_count} preview{'s' if preview_count != 1 else ''} for {format_id_str}" + # Build response dict for single mode + response_dict = { + "previews": validated_previews, + "interactive_url": str(interactive_url), + "expires_at": expires_at.isoformat(), + } + + # Return result + preview_count = len(validated_previews) + format_id_str = fmt_id.id if hasattr(fmt_id, "id") else str(fmt_id) + message = f"Generated {preview_count} preview{'s' if preview_count != 1 else ''} for {format_id_str}" + + return ToolResult( + content=[TextContent(type="text", text=message)], + structured_content=response_dict, + ) - return ToolResult( - content=[TextContent(type="text", text=message)], - structured_content=response.model_dump(mode="json", exclude_none=True), - ) - except ValueError as e: - error_msg = f"Invalid input: {e}" + +def _handle_batch_preview( + requests: list[dict[str, Any]], + default_output_format: str, +) -> ToolResult: + """Handle batch preview requests.""" + + if not requests or len(requests) == 0: + error_msg = "Batch mode requires at least one request" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], structured_content={"error": error_msg}, ) - except Exception as e: - import traceback - error_msg = f"Preview generation failed: {e}" + if len(requests) > 50: + error_msg = "Batch mode supports maximum 50 requests" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg, "traceback": traceback.format_exc()[-500:]}, + structured_content={"error": error_msg}, ) + results = [] + for req in requests: + try: + # Extract request params + req_format_id = req.get("format_id") + req_manifest = req.get("creative_manifest") + req_inputs = req.get("inputs") + req_template = req.get("template_id") + req_output_format = req.get("output_format", default_output_format) + + if not req_format_id or not req_manifest: + raise ValueError("Each request must have format_id and creative_manifest") + + # Process single preview + result = _handle_single_preview( + format_id=req_format_id, + creative_manifest=req_manifest, + inputs=req_inputs, + template_id=req_template, + output_format=req_output_format, + ) + + # Extract structured content from result + if "error" in result.structured_content: + results.append( + { + "success": False, + "error": { + "code": "preview_failed", + "message": result.structured_content["error"], + }, + } + ) + else: + results.append( + { + "success": True, + "response": result.structured_content, + } + ) + + except Exception as e: + results.append( + { + "success": False, + "error": { + "code": "request_error", + "message": str(e), + }, + } + ) + + # Build batch response + batch_response = {"results": results} + success_count = sum(1 for r in results if r.get("success")) + total_count = len(results) + message = ( + f"Processed {total_count} preview requests ({success_count} succeeded, {total_count - success_count} failed)" + ) + + return ToolResult( + content=[TextContent(type="text", text=message)], + structured_content=batch_response, + ) + def _generate_preview_variant( format_obj: Any, manifest: Any, input_set: Any, preview_id: str, - preview_url: str, + preview_url: str | None, + preview_html: str | None = None, ) -> dict[str, Any]: """Generate a single preview variant per ADCP spec. @@ -358,9 +488,6 @@ def _generate_preview_variant( from .schemas_generated._schemas_v1_creative_preview_creative_response_json import ( Dimensions, Embedding, - Input, - Preview, - Render, ) # Extract dimensions from format @@ -381,35 +508,43 @@ def _generate_preview_variant( ) # Create the single render (all formats render as HTML pages) - from pydantic import AnyUrl as PydanticUrl - from pydantic import ValidationError - - try: - render = Render( - render_id=f"{preview_id}-primary", - preview_url=PydanticUrl(preview_url), - role="primary", - dimensions=dimensions, - embedding=embedding, - ) - except ValidationError as e: - raise ValueError(f"Invalid preview URL '{preview_url}': {e}") from e + # Build as dict and let Pydantic validate with correct union variant + render_dict: dict[str, Any] = { + "render_id": f"{preview_id}-primary", + "role": "primary", + } + + if dimensions: + render_dict["dimensions"] = { + "width": dimensions.width, + "height": dimensions.height, + } + + render_dict["embedding"] = { + "recommended_sandbox": embedding.recommended_sandbox, + "requires_https": embedding.requires_https, + "supports_fullscreen": embedding.supports_fullscreen, + } + + if preview_url: + render_dict["preview_url"] = preview_url + if preview_html: + render_dict["preview_html"] = preview_html # Create input echo - input_echo = Input( - name=input_set.name, - macros=input_set.macros, - context_description=input_set.context_description if hasattr(input_set, "context_description") else None, - ) - - # Build Preview per spec - preview = Preview( - preview_id=preview_id, - renders=[render], - input=input_echo, - ) - - return preview.model_dump(mode="json", exclude_none=True) + input_dict = { + "name": input_set.name, + "macros": input_set.macros if input_set.macros else {}, + } + if hasattr(input_set, "context_description") and input_set.context_description: + input_dict["context_description"] = input_set.context_description + + # Build Preview per spec as dict + return { + "preview_id": preview_id, + "renders": [render_dict], + "input": input_dict, + } @mcp.tool() diff --git a/tests/integration/test_preview_creative.py b/tests/integration/test_preview_creative.py index fc5fe0c..455a19d 100644 --- a/tests/integration/test_preview_creative.py +++ b/tests/integration/test_preview_creative.py @@ -282,10 +282,11 @@ def test_preview_creative_returns_spec_compliant_response(self, mock_s3_upload): # Validate against actual ADCP spec response = PreviewCreativeResponse.model_validate(structured) - # Spec requires these fields - assert response.previews is not None - assert response.expires_at is not None - assert len(response.previews) == 3 # desktop, mobile, tablet + # For single mode response, access via .root + assert hasattr(response.root, "previews") + assert response.root.previews is not None + assert response.root.expires_at is not None + assert len(response.root.previews) == 3 # desktop, mobile, tablet def test_preview_expiration_is_valid_iso8601_timestamp(self, mock_s3_upload): """Test that expires_at is a valid ISO 8601 timestamp in the future.""" diff --git a/tests/integration/test_preview_html_and_batch.py b/tests/integration/test_preview_html_and_batch.py new file mode 100644 index 0000000..c10d986 --- /dev/null +++ b/tests/integration/test_preview_html_and_batch.py @@ -0,0 +1,316 @@ +"""Integration tests for HTML output and batch preview modes.""" + +import pytest + +from creative_agent import server +from creative_agent.data.standard_formats import AGENT_URL +from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_request_json import ( + Assets as ImageAsset, +) +from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_request_json import ( + Assets33 as UrlAsset, +) +from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_request_json import ( + CreativeManifest, + FormatId, +) +from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_response_json import ( + PreviewCreativeResponse, +) + +# Get the actual function from the FastMCP wrapper +preview_creative = server.preview_creative.fn + + +class TestHTMLOutputMode: + """Test HTML output format.""" + + @pytest.fixture(autouse=True) + def mock_s3_upload(self, mocker): + """Mock S3 upload for all tests.""" + return mocker.patch( + "creative_agent.storage.upload_preview_html", + return_value="https://adcp-previews.fly.storage.tigris.dev/previews/test-id/desktop.html", + ) + + def test_html_output_returns_preview_html(self): + """Test that output_format='html' returns preview_html field.""" + manifest = CreativeManifest( + format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), + assets={ + "banner_image": ImageAsset( + url="https://example.com/banner.png", + width=300, + height=250, + ), + "click_url": UrlAsset(url="https://example.com/landing"), + }, + ) + + result = preview_creative( + format_id="display_300x250_image", + creative_manifest=manifest.model_dump(mode="json"), + output_format="html", + ) + + structured = result.structured_content + + # Check for errors first + if "error" in structured: + pytest.fail(f"Preview failed with error: {structured['error']}") + + # Validate response structure + response = PreviewCreativeResponse.model_validate(structured) + assert hasattr(response.root, "previews") + + # Check that preview_html is present + first_preview = response.root.previews[0] + first_render = first_preview.renders[0] + assert first_render.preview_html is not None + assert isinstance(first_render.preview_html, str) + assert len(first_render.preview_html) > 0 + + # HTML should contain expected elements + assert " 0 + + def test_batch_mode_handles_errors_gracefully(self): + """Test that batch mode handles individual request errors.""" + valid_manifest = CreativeManifest( + format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), + assets={ + "banner_image": ImageAsset( + url="https://example.com/banner.png", + width=300, + height=250, + ), + "click_url": UrlAsset(url="https://example.com/landing"), + }, + ) + + # Second request has invalid format + result = preview_creative( + requests=[ + { + "format_id": "display_300x250_image", + "creative_manifest": valid_manifest.model_dump(mode="json"), + }, + { + "format_id": "nonexistent_format", + "creative_manifest": valid_manifest.model_dump(mode="json"), + }, + ] + ) + + structured = result.structured_content + + # Should have 2 results + assert len(structured["results"]) == 2 + + # First should succeed + assert structured["results"][0]["success"] is True + + # Second should fail + assert structured["results"][1]["success"] is False + assert "error" in structured["results"][1] + assert "message" in structured["results"][1]["error"] + + def test_batch_mode_per_request_output_format_override(self): + """Test that individual requests can override batch output_format.""" + manifest = CreativeManifest( + format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), + assets={ + "banner_image": ImageAsset( + url="https://example.com/banner.png", + width=300, + height=250, + ), + "click_url": UrlAsset(url="https://example.com/landing"), + }, + ) + + result = preview_creative( + requests=[ + { + "format_id": "display_300x250_image", + "creative_manifest": manifest.model_dump(mode="json"), + "output_format": "url", # Override to URL + }, + { + "format_id": "display_300x250_image", + "creative_manifest": manifest.model_dump(mode="json"), + # Uses default HTML from batch level + }, + ], + output_format="html", # Batch level default + ) + + structured = result.structured_content + + # First request should have URL (overridden) + first_render = structured["results"][0]["response"]["previews"][0]["renders"][0] + assert "preview_url" in first_render + + # Second request should have HTML (default) + second_render = structured["results"][1]["response"]["previews"][0]["renders"][0] + assert "preview_html" in second_render diff --git a/tests/integration/test_tool_response_formats.py b/tests/integration/test_tool_response_formats.py index 7ccfbd5..590c181 100644 --- a/tests/integration/test_tool_response_formats.py +++ b/tests/integration/test_tool_response_formats.py @@ -218,9 +218,10 @@ def test_structured_content_matches_adcp_schema(self, valid_manifest, mock_s3): # This validates ALL fields per ADCP spec response = PreviewCreativeResponse.model_validate(result.structured_content) - # Verify required fields per spec - assert response.previews is not None, "'previews' is required per spec" - assert response.expires_at is not None, "'expires_at' is required per spec" + # Verify required fields per spec (access via .root for union type) + assert hasattr(response.root, "previews"), "'previews' is required per spec" + assert response.root.previews is not None, "'previews' is required per spec" + assert response.root.expires_at is not None, "'expires_at' is required per spec" def test_previews_array_structure(self, valid_manifest, mock_s3): """Per spec, previews must be array of Preview objects with renders.""" @@ -230,10 +231,11 @@ def test_previews_array_structure(self, valid_manifest, mock_s3): ) response = PreviewCreativeResponse.model_validate(result.structured_content) - assert isinstance(response.previews, list), "previews must be array" - assert len(response.previews) > 0, "must return at least one preview" + # Access via .root for union type + assert isinstance(response.root.previews, list), "previews must be array" + assert len(response.root.previews) > 0, "must return at least one preview" - for preview in response.previews: + for preview in response.root.previews: # Per spec, each Preview must have: assert preview.preview_id is not None, "preview_id is required per spec" assert preview.renders is not None, "renders is required per spec" diff --git a/tests/schemas/v1/_schemas_v1_creative_preview-creative-request_json.json b/tests/schemas/v1/_schemas_v1_creative_preview-creative-request_json.json index 9ac7508..b7cc947 100644 --- a/tests/schemas/v1/_schemas_v1_creative_preview-creative-request_json.json +++ b/tests/schemas/v1/_schemas_v1_creative_preview-creative-request_json.json @@ -2,53 +2,142 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/v1/creative/preview-creative-request.json", "title": "Preview Creative Request", - "description": "Request to generate a preview of a creative manifest in a specific format. The creative_manifest should include all assets required by the format (e.g., promoted_offerings for generative formats).", - "type": "object", - "properties": { - "format_id": { - "$ref": "/schemas/v1/core/format-id.json", - "description": "Format identifier for rendering the preview" - }, - "creative_manifest": { - "$ref": "/schemas/v1/core/creative-manifest.json", - "description": "Complete creative manifest with all required assets (including promoted_offerings if required by the format)" - }, - "inputs": { - "type": "array", - "description": "Array of input sets for generating multiple preview variants. Each input set defines macros and context values for one preview rendering. If not provided, creative agent will generate default previews.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad', 'Desktop dark mode')" - }, - "macros": { + "description": "Request to generate previews of one or more creative manifests. Accepts either a single creative request or an array of requests for batch processing.", + "oneOf": [ + { + "type": "object", + "description": "Single creative preview request", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Format identifier for rendering the preview" + }, + "creative_manifest": { + "$ref": "/schemas/v1/core/creative-manifest.json", + "description": "Complete creative manifest with all required assets (including promoted_offerings if required by the format)" + }, + "inputs": { + "type": "array", + "description": "Array of input sets for generating multiple preview variants. Each input set defines macros and context values for one preview rendering. If not provided, creative agent will generate default previews.", + "items": { "type": "object", - "description": "Macro values to use for this preview. Supports all universal macros from the format's supported_macros list. See docs/media-buy/creatives/universal-macros.md for available macros.", - "additionalProperties": { - "type": "string" - } - }, - "context_description": { - "type": "string", - "description": "Natural language description of the context for AI-generated content (e.g., 'User just searched for running shoes', 'Podcast discussing weather patterns', 'Article about electric vehicles')" + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad', 'Desktop dark mode')" + }, + "macros": { + "type": "object", + "description": "Macro values to use for this preview. Supports all universal macros from the format's supported_macros list. See docs/media-buy/creatives/universal-macros.md for available macros.", + "additionalProperties": { + "type": "string" + } + }, + "context_description": { + "type": "string", + "description": "Natural language description of the context for AI-generated content (e.g., 'User just searched for running shoes', 'Podcast discussing weather patterns', 'Article about electric vehicles')" + } + }, + "required": [ + "name" + ], + "additionalProperties": false } }, - "required": [ - "name" - ], - "additionalProperties": false - } + "template_id": { + "type": "string", + "description": "Specific template ID for custom format rendering" + }, + "output_format": { + "type": "string", + "enum": ["url", "html"], + "default": "url", + "description": "Output format for previews. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML for direct embedding). Default: 'url' for backward compatibility." + } + }, + "required": [ + "format_id", + "creative_manifest" + ], + "additionalProperties": false }, - "template_id": { - "type": "string", - "description": "Specific template ID for custom format rendering" + { + "type": "object", + "description": "Batch preview request for multiple creatives (5-10x faster than individual calls)", + "properties": { + "requests": { + "type": "array", + "description": "Array of preview requests (1-50 items). Each follows the single request structure.", + "items": { + "type": "object", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Format identifier for rendering the preview" + }, + "creative_manifest": { + "$ref": "/schemas/v1/core/creative-manifest.json", + "description": "Complete creative manifest with all required assets" + }, + "inputs": { + "type": "array", + "description": "Array of input sets for generating multiple preview variants", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for this input set" + }, + "macros": { + "type": "object", + "description": "Macro values to use for this preview", + "additionalProperties": { + "type": "string" + } + }, + "context_description": { + "type": "string", + "description": "Natural language description of the context for AI-generated content" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "template_id": { + "type": "string", + "description": "Specific template ID for custom format rendering" + }, + "output_format": { + "type": "string", + "enum": ["url", "html"], + "default": "url", + "description": "Output format for this preview. 'url' returns preview_url, 'html' returns preview_html." + } + }, + "required": [ + "format_id", + "creative_manifest" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 50 + }, + "output_format": { + "type": "string", + "enum": ["url", "html"], + "default": "url", + "description": "Default output format for all requests in this batch. Individual requests can override this. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML for direct embedding)." + } + }, + "required": [ + "requests" + ], + "additionalProperties": false } - }, - "required": [ - "format_id", - "creative_manifest" - ], - "additionalProperties": false + ] } diff --git a/tests/schemas/v1/_schemas_v1_creative_preview-creative-response_json.json b/tests/schemas/v1/_schemas_v1_creative_preview-creative-response_json.json index d817d61..aa573be 100644 --- a/tests/schemas/v1/_schemas_v1_creative_preview-creative-response_json.json +++ b/tests/schemas/v1/_schemas_v1_creative_preview-creative-response_json.json @@ -2,134 +2,314 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/v1/creative/preview-creative-response.json", "title": "Preview Creative Response", - "description": "Response containing preview links for a creative. Each preview URL returns an HTML page that can be embedded in an iframe to display the rendered creative.", - "type": "object", - "properties": { - "previews": { - "type": "array", - "description": "Array of preview variants. Each preview corresponds to an input set from the request. If no inputs were provided, returns a single default preview.", - "items": { - "type": "object", - "properties": { - "preview_id": { - "type": "string", - "description": "Unique identifier for this preview variant" - }, - "renders": { - "type": "array", - "description": "Array of rendered pieces for this preview variant. Most formats render as a single piece. Companion ad formats (video + banner), multi-placement formats, and adaptive formats render as multiple pieces.", - "items": { - "type": "object", - "properties": { - "render_id": { - "type": "string", - "description": "Unique identifier for this rendered piece within the variant" - }, - "preview_url": { - "type": "string", - "format": "uri", - "description": "URL to an HTML page that renders this piece. Can be embedded in an iframe. Handles all rendering complexity internally (images, video players, audio players, interactive content, etc.)." - }, - "role": { - "type": "string", - "description": "Semantic role of this rendered piece. Use 'primary' for main content, 'companion' for associated banners, descriptive strings for device variants or custom roles." - }, - "dimensions": { - "type": "object", - "description": "Dimensions for this rendered piece. For companion ads with multiple sizes, this specifies which size this piece is.", - "properties": { - "width": { - "type": "number", - "minimum": 0 - }, - "height": { - "type": "number", - "minimum": 0 - } - }, - "required": [ - "width", - "height" - ] - }, - "embedding": { + "description": "Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests.", + "oneOf": [ + { + "type": "object", + "description": "Single preview response - each preview URL returns an HTML page that can be embedded in an iframe", + "properties": { + "previews": { + "type": "array", + "description": "Array of preview variants. Each preview corresponds to an input set from the request. If no inputs were provided, returns a single default preview.", + "items": { + "type": "object", + "properties": { + "preview_id": { + "type": "string", + "description": "Unique identifier for this preview variant" + }, + "renders": { + "type": "array", + "description": "Array of rendered pieces for this preview variant. Most formats render as a single piece. Companion ad formats (video + banner), multi-placement formats, and adaptive formats render as multiple pieces.", + "items": { "type": "object", - "description": "Optional security and embedding metadata for safe iframe integration", "properties": { - "recommended_sandbox": { + "render_id": { "type": "string", - "description": "Recommended iframe sandbox attribute value (e.g., 'allow-scripts allow-same-origin')" + "description": "Unique identifier for this rendered piece within the variant" }, - "requires_https": { - "type": "boolean", - "description": "Whether this output requires HTTPS for secure embedding" + "preview_url": { + "type": "string", + "format": "uri", + "description": "URL to an HTML page that renders this piece. Can be embedded in an iframe. Typically returned when output_format='url' (default). Creative agents MAY provide both preview_url and preview_html for client flexibility." }, - "supports_fullscreen": { - "type": "boolean", - "description": "Whether this output supports fullscreen mode" + "preview_html": { + "type": "string", + "description": "Raw HTML for this rendered piece. Can be embedded directly in the page without iframe. Typically returned when output_format='html'. Security warning: Only use with trusted creative agents as this bypasses iframe sandboxing. Creative agents MAY provide both formats." }, - "csp_policy": { + "role": { "type": "string", - "description": "Content Security Policy requirements for embedding" + "description": "Semantic role of this rendered piece. Use 'primary' for main content, 'companion' for associated banners, descriptive strings for device variants or custom roles." + }, + "dimensions": { + "type": "object", + "description": "Dimensions for this rendered piece. For companion ads with multiple sizes, this specifies which size this piece is.", + "properties": { + "width": { + "type": "number", + "minimum": 0 + }, + "height": { + "type": "number", + "minimum": 0 + } + }, + "required": ["width", "height"] + }, + "embedding": { + "type": "object", + "description": "Optional security and embedding metadata for safe iframe integration", + "properties": { + "recommended_sandbox": { + "type": "string", + "description": "Recommended iframe sandbox attribute value (e.g., 'allow-scripts allow-same-origin')" + }, + "requires_https": { + "type": "boolean", + "description": "Whether this output requires HTTPS for secure embedding" + }, + "supports_fullscreen": { + "type": "boolean", + "description": "Whether this output supports fullscreen mode" + }, + "csp_policy": { + "type": "string", + "description": "Content Security Policy requirements for embedding" + } + } } - } - } + }, + "required": ["render_id", "role"], + "oneOf": [ + { + "required": ["preview_url"] + }, + { + "required": ["preview_html"] + }, + { + "required": ["preview_url", "preview_html"] + } + ] + }, + "minItems": 1 }, - "required": [ - "render_id", - "preview_url", - "role" - ] + "input": { + "type": "object", + "description": "The input parameters that generated this preview variant. Echoes back the request input or shows defaults used.", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for this variant" + }, + "macros": { + "type": "object", + "description": "Macro values applied to this variant", + "additionalProperties": { + "type": "string" + } + }, + "context_description": { + "type": "string", + "description": "Context description applied to this variant" + } + }, + "required": ["name"] + } }, - "minItems": 1 + "required": ["preview_id", "renders", "input"] }, - "input": { + "minItems": 1 + }, + "interactive_url": { + "type": "string", + "format": "uri", + "description": "Optional URL to an interactive testing page that shows all preview variants with controls to switch between them, modify macro values, and test different scenarios." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when preview links expire" + } + }, + "required": [ + "previews", + "expires_at" + ], + "additionalProperties": false + }, + { + "type": "object", + "description": "Batch preview response - contains results for multiple creative requests", + "properties": { + "results": { + "type": "array", + "description": "Array of preview results corresponding to each request in the same order. results[0] is the result for requests[0], results[1] for requests[1], etc. Order is guaranteed even when some requests fail. Each result contains either a successful preview response or an error.", + "items": { "type": "object", - "description": "The input parameters that generated this preview variant. Echoes back the request input or shows defaults used.", "properties": { - "name": { - "type": "string", - "description": "Human-readable name for this variant" + "success": { + "type": "boolean", + "description": "Whether this preview request succeeded" }, - "macros": { + "response": { "type": "object", - "description": "Macro values applied to this variant", - "additionalProperties": { - "type": "string" - } + "description": "Preview response for successful requests", + "properties": { + "previews": { + "type": "array", + "description": "Array of preview variants for this creative", + "items": { + "type": "object", + "properties": { + "preview_id": { + "type": "string" + }, + "renders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "render_id": { + "type": "string" + }, + "preview_url": { + "type": "string", + "format": "uri", + "description": "URL to iframe-embeddable HTML page. Typically present when output_format='url'." + }, + "preview_html": { + "type": "string", + "description": "Raw HTML for direct embedding. Typically present when output_format='html'. Security: Only use with trusted agents." + }, + "role": { + "type": "string" + }, + "dimensions": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0 + }, + "height": { + "type": "number", + "minimum": 0 + } + }, + "required": ["width", "height"] + }, + "embedding": { + "type": "object", + "properties": { + "recommended_sandbox": { + "type": "string" + }, + "requires_https": { + "type": "boolean" + }, + "supports_fullscreen": { + "type": "boolean" + }, + "csp_policy": { + "type": "string" + } + } + } + }, + "required": ["render_id", "role"], + "oneOf": [ + { + "required": ["preview_url"] + }, + { + "required": ["preview_html"] + }, + { + "required": ["preview_url", "preview_html"] + } + ] + }, + "minItems": 1 + }, + "input": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "macros": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "context_description": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["preview_id", "renders", "input"] + }, + "minItems": 1 + }, + "interactive_url": { + "type": "string", + "format": "uri" + }, + "expires_at": { + "type": "string", + "format": "date-time" + } + }, + "required": ["previews", "expires_at"] }, - "context_description": { - "type": "string", - "description": "Context description applied to this variant" + "error": { + "type": "object", + "description": "Error information for failed requests", + "properties": { + "code": { + "type": "string", + "description": "Error code (e.g., 'invalid_manifest', 'unsupported_format', 'missing_assets')" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "details": { + "type": "object", + "description": "Additional error context", + "additionalProperties": true + } + }, + "required": ["code", "message"] } }, - "required": [ - "name" + "required": ["success"], + "oneOf": [ + { + "properties": { + "success": { "const": true } + }, + "required": ["response"] + }, + { + "properties": { + "success": { "const": false } + }, + "required": ["error"] + } ] - } - }, - "required": [ - "preview_id", - "renders", - "input" - ] + }, + "minItems": 1 + } }, - "minItems": 1 - }, - "interactive_url": { - "type": "string", - "format": "uri", - "description": "Optional URL to an interactive testing page that shows all preview variants with controls to switch between them, modify macro values, and test different scenarios." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when preview links expire" + "required": [ + "results" + ], + "additionalProperties": false } - }, - "required": [ - "previews", - "expires_at" - ], - "additionalProperties": false + ] } From 836f1501a7e7a3a1499d35e9af18423becce0070 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 7 Nov 2025 07:21:37 -0500 Subject: [PATCH 2/3] Update examples with batch mode and HTML output usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for new batch mode and HTML output features in examples/README.md, including: - React example using batch preview with HTML output for format showcases - When to use batch mode vs single mode - When to use HTML output vs URL output - Batch + HTML combined example with error handling - Per-request output format override examples - Batch mode response structure documentation - HTML output response structure documentation This addresses the original question about making grids work without persistence - batch mode with HTML output is the perfect solution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/README.md | 164 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index e9645ff..94c8a05 100644 --- a/examples/README.md +++ b/examples/README.md @@ -49,18 +49,46 @@ The `` web component provides the easiest way to embed creati ``` -### React Example +### React Example (with Batch Preview) ```jsx -function ProductGrid({ previews }) { +import { useEffect, useState } from 'react'; + +function FormatShowcaseGrid() { + const [previews, setPreviews] = useState([]); + + useEffect(() => { + async function loadPreviews() { + // 1. List all formats + const formats = await creativeAgent.list_creative_formats(); + + // 2. Batch preview all formats with HTML output (no S3 uploads!) + const response = await creativeAgent.preview_creative({ + output_format: "html", // Get HTML directly + requests: formats.formats.map(format => ({ + format_id: format.format_id, + creative_manifest: format.format_card.manifest, + inputs: [{ name: "Desktop", macros: { DEVICE_TYPE: "desktop" } }] + })) + }); + + // 3. Extract successful previews + const successful = response.results + .filter(r => r.success) + .map(r => r.response.previews[0]); + + setPreviews(successful); + } + + loadPreviews(); + }, []); + return (
{previews.map(preview => ( - ))}
@@ -68,6 +96,11 @@ function ProductGrid({ previews }) { } ``` +**Why batch mode?** +- **5-10x faster**: One API call instead of N calls +- **No storage**: HTML output bypasses S3 entirely +- **Simpler**: No need to track/cleanup uploaded files + ### Attributes | Attribute | Type | Default | Description | @@ -119,9 +152,80 @@ If you need maximum isolation or can't use web components, you can still use ifr **Note**: iframes are heavier and harder to style as a cohesive grid, but provide the strongest isolation. +## Batch Mode & HTML Output (New!) + +### When to Use Batch Mode + +Use batch mode when you need to preview multiple creatives at once: +- **Format showcases** (previewing all available formats) +- **Campaign reviews** (previewing all creatives in a campaign) +- **A/B testing grids** (comparing multiple creative variants) + +### When to Use HTML Output + +Use HTML output (`output_format="html"`) when: +- Embedding previews directly in your page (no iframe needed) +- Building preview grids with 50+ items +- You don't need shareable URLs +- You want to avoid S3 storage costs + +### Batch + HTML Example + +```javascript +// Preview 10 different creatives in one API call with HTML output +const response = await creativeAgent.preview_creative({ + output_format: "html", // Default for all requests + requests: [ + { + format_id: "display_300x250_image", + creative_manifest: manifest1 + }, + { + format_id: "display_728x90_image", + creative_manifest: manifest2 + }, + // ... up to 50 requests + ] +}); + +// Handle results (some may fail) +response.results.forEach((result, idx) => { + if (result.success) { + const html = result.response.previews[0].renders[0].preview_html; + document.getElementById(`preview-${idx}`).innerHTML = html; + } else { + console.error(`Preview ${idx} failed:`, result.error.message); + } +}); +``` + +### Per-Request Output Override + +You can mix output formats in a batch: + +```javascript +const response = await creativeAgent.preview_creative({ + output_format: "html", // Default + requests: [ + { + format_id: "format1", + creative_manifest: manifest1, + output_format: "url" // Override: this one returns a URL + }, + { + format_id: "format2", + creative_manifest: manifest2 + // Uses default "html" + } + ] +}); +``` + ## API Response Structure -When you call `preview_creative`, you get: +### Single Mode Response + +When you call `preview_creative` in single mode, you get: ```json { @@ -150,3 +254,49 @@ When you call `preview_creative`, you get: ``` Just pass `preview_url` to the web component's `src` attribute! + +### Batch Mode Response + +When you call `preview_creative` in batch mode, you get: + +```json +{ + "results": [ + { + "success": true, + "response": { + "previews": [...], + "interactive_url": "...", + "expires_at": "..." + } + }, + { + "success": false, + "error": { + "code": "preview_failed", + "message": "Format not found" + } + } + ] +} +``` + +Each result has a `success` field. Check this before accessing `response` or `error`. + +### HTML Output Response + +When using `output_format="html"`, the response includes `preview_html` instead of (or in addition to) `preview_url`: + +```json +{ + "previews": [{ + "renders": [{ + "preview_html": "
...
", + "role": "primary", + "dimensions": { "width": 300, "height": 250 } + }] + }] +} +``` + +Use `dangerouslySetInnerHTML` in React or `innerHTML` in vanilla JS to render it. From 26819fdb53b46709aa70132c400cf6a7baf85d8f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 7 Nov 2025 07:31:40 -0500 Subject: [PATCH 3/3] Fix mypy type error in batch preview handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle case where result.structured_content can be None by defaulting to empty dict. This fixes the CI failure: - src/creative_agent/server.py:429:16: error: Unsupported right operand type for in ("dict[str, Any] | None") - src/creative_agent/server.py:435:40: error: Value of type "dict[str, Any] | None" is not indexable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/creative_agent/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/creative_agent/server.py b/src/creative_agent/server.py index d723687..4423c40 100644 --- a/src/creative_agent/server.py +++ b/src/creative_agent/server.py @@ -426,13 +426,14 @@ def _handle_batch_preview( ) # Extract structured content from result - if "error" in result.structured_content: + structured = result.structured_content or {} + if "error" in structured: results.append( { "success": False, "error": { "code": "preview_failed", - "message": result.structured_content["error"], + "message": structured["error"], }, } ) @@ -440,7 +441,7 @@ def _handle_batch_preview( results.append( { "success": True, - "response": result.structured_content, + "response": structured, } )