diff --git a/alembic/versions/28cdf399fb73_add_format_parameters_to_creatives.py b/alembic/versions/28cdf399fb73_add_format_parameters_to_creatives.py new file mode 100644 index 000000000..987199daf --- /dev/null +++ b/alembic/versions/28cdf399fb73_add_format_parameters_to_creatives.py @@ -0,0 +1,49 @@ +"""add format_parameters to creatives + +Revision ID: 28cdf399fb73 +Revises: 4b11f64bbebe +Create Date: 2025-12-25 14:37:08.561847 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "28cdf399fb73" +down_revision: Union[str, Sequence[str], None] = "4b11f64bbebe" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add format_parameters JSONB column to creatives table. + + This column stores parameterized FormatId fields (width, height, duration_ms) + for creative format templates (AdCP 2.5). When present, these parameters + combined with agent_url and format create a parameterized format ID. + + Example values: + - {"width": 300, "height": 250} # Display creative + - {"duration_ms": 15000} # Video creative (15 seconds) + - {"width": 1920, "height": 1080, "duration_ms": 30000} # Video with dimensions + - NULL # Template format without parameters + """ + op.add_column( + "creatives", + sa.Column( + "format_parameters", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="Parameterized FormatId fields (width, height, duration_ms) for format templates", + ), + ) + + +def downgrade() -> None: + """Remove format_parameters column.""" + op.drop_column("creatives", "format_parameters") diff --git a/alembic/versions/46b1bf1c82c5_merge_format_parameters_and_gam_network_.py b/alembic/versions/46b1bf1c82c5_merge_format_parameters_and_gam_network_.py new file mode 100644 index 000000000..69a3c0450 --- /dev/null +++ b/alembic/versions/46b1bf1c82c5_merge_format_parameters_and_gam_network_.py @@ -0,0 +1,29 @@ +"""merge format_parameters and gam_network_currency + +Revision ID: 46b1bf1c82c5 +Revises: 28cdf399fb73, add_gam_network_currency +Create Date: 2025-12-28 22:25:23.872932 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "46b1bf1c82c5" +down_revision: Union[str, Sequence[str], None] = ("28cdf399fb73", "add_gam_network_currency") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/config/nginx/nginx-development.conf b/config/nginx/nginx-development.conf index e361ffc74..a3eb36653 100644 --- a/config/nginx/nginx-development.conf +++ b/config/nginx/nginx-development.conf @@ -33,6 +33,15 @@ http { listen 8000; server_name _; + # Use Docker's internal DNS resolver for dynamic upstream resolution + # This allows nginx to start before upstreams are available + resolver 127.0.0.11 valid=30s ipv6=off; + + # Define upstream variables (resolved at request time, not config load) + set $adcp_server_mcp "adcp-server:8080"; + set $adcp_server_a2a "adcp-server:8091"; + set $admin_ui "admin-ui:8001"; + # Health check location /health { access_log off; @@ -42,7 +51,7 @@ http { # MCP endpoint location /mcp { - proxy_pass http://adcp-server:8080; + proxy_pass http://$adcp_server_mcp$request_uri; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -57,7 +66,7 @@ http { # A2A endpoints location /a2a { - proxy_pass http://adcp-server:8091; + proxy_pass http://$adcp_server_a2a$request_uri; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -71,7 +80,7 @@ http { } location /.well-known/ { - proxy_pass http://adcp-server:8091/.well-known/; + proxy_pass http://$adcp_server_a2a$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -80,7 +89,7 @@ http { } location /agent.json { - proxy_pass http://adcp-server:8091/agent.json; + proxy_pass http://$adcp_server_a2a$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -90,7 +99,7 @@ http { # Admin UI routes location /admin { - proxy_pass http://admin-ui:8001/admin; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -104,7 +113,7 @@ http { } location /static { - proxy_pass http://admin-ui:8001/static; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -113,7 +122,7 @@ http { } location /auth { - proxy_pass http://admin-ui:8001/auth; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -122,7 +131,7 @@ http { } location /api { - proxy_pass http://admin-ui:8001/api; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -131,7 +140,7 @@ http { } location /callback { - proxy_pass http://admin-ui:8001/callback; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -140,7 +149,7 @@ http { } location /logout { - proxy_pass http://admin-ui:8001/logout; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -149,7 +158,7 @@ http { } location /login { - proxy_pass http://admin-ui:8001/login; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -158,7 +167,7 @@ http { } location /signup { - proxy_pass http://admin-ui:8001/signup; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -167,7 +176,7 @@ http { } location /tenant { - proxy_pass http://admin-ui:8001/tenant; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -176,7 +185,7 @@ http { } location /test { - proxy_pass http://admin-ui:8001/test; + proxy_pass http://$admin_ui$request_uri; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -186,7 +195,7 @@ http { # Root - proxy to admin-ui which serves the landing page location = / { - proxy_pass http://admin-ui:8001/; + proxy_pass http://$admin_ui/; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; diff --git a/src/admin/blueprints/format_search.py b/src/admin/blueprints/format_search.py index 8879463b9..b3c2b6a6a 100644 --- a/src/admin/blueprints/format_search.py +++ b/src/admin/blueprints/format_search.py @@ -161,6 +161,101 @@ def list_all_formats(): return jsonify({"error": str(e)}), 500 +@bp.route("/templates", methods=["GET"]) +@require_auth() +def get_format_templates(): + """Get format templates for the template picker UI. + + Returns format templates with metadata for parameterized format selection. + Templates represent base formats (display_static, video_hosted, native) + that can be configured with width/height/duration parameters. + + Query parameters: + adapter_type: Optional adapter type filter ('gam' or 'mock') + + Returns: + JSON object with templates and common sizes from GAM_STANDARD_SIZES + """ + adapter_type = request.args.get("adapter_type", "mock") + + # Import GAM standard sizes for common size quick-picks + try: + from src.adapters.gam.utils.constants import GAM_STANDARD_SIZES + except ImportError: + GAM_STANDARD_SIZES = {} + + # Format templates definition + templates = { + "display_static": { + "id": "display_static", + "name": "Static Display", + "description": "Display banner ads (image, JS, or HTML5 - auto-detected at upload)", + "type": "display", + "parameter_type": "dimensions", + "gam_supported": True, + }, + "video_hosted": { + "id": "video_hosted", + "name": "Hosted Video", + "description": "Video ads hosted on creative agent (MP4, WebM)", + "type": "video", + "parameter_type": "both", + "gam_supported": True, + }, + "video_vast": { + "id": "video_vast", + "name": "VAST Tag", + "description": "Video ads served via VAST XML redirect", + "type": "video", + "parameter_type": "both", + "gam_supported": True, + }, + "native": { + "id": "native", + "name": "Native Ad", + "description": "Native content ads that match the look of the site", + "type": "native", + "parameter_type": "none", + "gam_supported": True, + }, + "audio": { + "id": "audio", + "name": "Audio Ad", + "description": "Audio-only ads for podcasts and streaming", + "type": "audio", + "parameter_type": "duration", + "gam_supported": False, + }, + } + + # Filter for GAM adapter (no audio support) + if adapter_type == "gam": + templates = {k: v for k, v in templates.items() if v.get("gam_supported", True)} + + # Convert GAM_STANDARD_SIZES to list format + common_sizes = [] + for name, dims in GAM_STANDARD_SIZES.items(): + if isinstance(dims, tuple) and len(dims) == 2: + common_sizes.append( + { + "name": name.replace("_", " ").title(), + "width": dims[0], + "height": dims[1], + } + ) + + # Sort by width then height + common_sizes.sort(key=lambda s: (s["width"], s["height"])) + + return jsonify( + { + "templates": templates, + "common_sizes": common_sizes, + "default_agent_url": "https://creative.adcontextprotocol.org", + } + ) + + @bp.route("/agents", methods=["GET"]) @require_auth() def list_creative_agents(): diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index 692dc175c..e19ab913a 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -1261,3 +1261,101 @@ def get_inventory_list(tenant_id): except Exception as e: logger.error(f"Error fetching inventory list: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 + + +@inventory_bp.route("/api/tenant//inventory/sizes", methods=["GET"]) +@require_tenant_access(api_mode=True) +def get_inventory_sizes(tenant_id): + """Get unique creative sizes from selected inventory items. + + Extracts sizes from inventory metadata (ad units and placements) for + auto-populating the format template picker with available sizes. + + Query Parameters: + ids: Comma-separated list of inventory_ids (ad units or placements) + profile_id: Inventory profile ID (alternative to ids) + + Returns: + JSON object with unique sizes sorted by width, e.g.: + { + "sizes": ["300x250", "728x90", "970x250"], + "count": 3 + } + """ + try: + ids_param = request.args.get("ids", "").strip() + profile_id = request.args.get("profile_id", "").strip() + + inventory_ids = [] + + # Get inventory IDs from profile if provided + if profile_id: + from src.core.database.models import InventoryProfile + + with get_db_session() as db_session: + profile = db_session.scalars( + select(InventoryProfile).filter_by(tenant_id=tenant_id, profile_id=profile_id) + ).first() + + if not profile: + return jsonify({"error": "Inventory profile not found"}), 404 + + # Extract inventory IDs from profile configuration + profile_config = profile.config or {} + inventory_ids = profile_config.get("inventory_ids", []) + # Also check for ad_units and placements in the config + if "ad_units" in profile_config: + inventory_ids.extend(profile_config["ad_units"]) + if "placements" in profile_config: + inventory_ids.extend(profile_config["placements"]) + + # Also parse any directly provided IDs + if ids_param: + direct_ids = [id.strip() for id in ids_param.split(",") if id.strip()] + inventory_ids.extend(direct_ids) + + if not inventory_ids: + return jsonify({"sizes": [], "count": 0}) + + # Query inventory items + with get_db_session() as db_session: + stmt = select(GAMInventory).filter( + GAMInventory.tenant_id == tenant_id, + GAMInventory.inventory_id.in_(inventory_ids), + ) + items = db_session.scalars(stmt).all() + + # Extract sizes from inventory metadata + sizes = set() + for item in items: + metadata = item.inventory_metadata or {} + if not isinstance(metadata, dict): + continue + + # Get sizes from metadata (array of "WxH" strings) + item_sizes = metadata.get("sizes", []) + if isinstance(item_sizes, list): + for size in item_sizes: + if isinstance(size, str) and "x" in size: + sizes.add(size) + + # Sort sizes by width, then height + def size_sort_key(s): + try: + w, h = s.split("x") + return (int(w), int(h)) + except (ValueError, AttributeError): + return (0, 0) + + sorted_sizes = sorted(sizes, key=size_sort_key) + + logger.info( + f"Extracted {len(sorted_sizes)} unique sizes from " + f"{len(items)} inventory items for tenant {tenant_id}" + ) + + return jsonify({"sizes": sorted_sizes, "count": len(sorted_sizes)}) + + except Exception as e: + logger.error(f"Error fetching inventory sizes: {e}", exc_info=True) + return jsonify({"error": str(e)}), 500 diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index 87291176c..5e0f6bf71 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -697,6 +697,8 @@ def add_product(tenant_id): with get_db_session() as db_session: # Parse formats - expecting JSON string with FormatReference objects + # Supports both legacy format {agent_url, format_id} and new parameterized format + # {agent_url, id, width?, height?, duration_ms?} formats_json = form_data.get("formats", "[]") or "[]" formats = [] try: @@ -709,7 +711,7 @@ def add_product(tenant_id): registry = get_creative_agent_registry() available_formats = asyncio.run(registry.list_all_formats(tenant_id=tenant_id)) - # Build lookup of valid format IDs + # Build lookup of valid format IDs from live agent valid_format_ids = set() for fmt in available_formats: format_id_str = fmt.format_id.id if hasattr(fmt.format_id, "id") else str(fmt.format_id) @@ -717,12 +719,29 @@ def add_product(tenant_id): invalid_formats = [] for fmt in formats_parsed: - if isinstance(fmt, dict) and fmt.get("agent_url") and fmt.get("format_id"): - format_id = fmt["format_id"] - if format_id in valid_format_ids: - formats.append({"agent_url": fmt["agent_url"], "id": format_id}) - else: - invalid_formats.append(format_id) + if not isinstance(fmt, dict) or not fmt.get("agent_url"): + continue + + # Support both legacy {format_id} and new {id} field names + format_id = fmt.get("id") or fmt.get("format_id") + if not format_id: + continue + + if format_id in valid_format_ids: + # Build format dict with parameterized fields + format_entry = {"agent_url": fmt["agent_url"], "id": format_id} + + # Include optional dimension/duration parameters + if fmt.get("width") is not None: + format_entry["width"] = int(fmt["width"]) + if fmt.get("height") is not None: + format_entry["height"] = int(fmt["height"]) + if fmt.get("duration_ms") is not None: + format_entry["duration_ms"] = float(fmt["duration_ms"]) + + formats.append(format_entry) + else: + invalid_formats.append(format_id) # Block save if any formats are invalid if invalid_formats: @@ -739,8 +758,20 @@ def add_product(tenant_id): # Creative agent unreachable - graceful degradation logger.warning(f"Creative agent unreachable, saving formats without validation: {e}") for fmt in formats_parsed: - if isinstance(fmt, dict) and fmt.get("agent_url") and fmt.get("format_id"): - formats.append({"agent_url": fmt["agent_url"], "id": fmt["format_id"]}) + if not isinstance(fmt, dict) or not fmt.get("agent_url"): + continue + format_id = fmt.get("id") or fmt.get("format_id") + if not format_id: + continue + + format_entry = {"agent_url": fmt["agent_url"], "id": format_id} + if fmt.get("width") is not None: + format_entry["width"] = int(fmt["width"]) + if fmt.get("height") is not None: + format_entry["height"] = int(fmt["height"]) + if fmt.get("duration_ms") is not None: + format_entry["duration_ms"] = float(fmt["duration_ms"]) + formats.append(format_entry) flash( "Format validation unavailable (creative agent unreachable). " "Formats will be verified when creating media buys.", @@ -1322,11 +1353,12 @@ def edit_product(tenant_id, product_id): validated_formats = None if request.method == "POST": # Parse formats - expecting JSON string with FormatReference objects + # Supports both legacy format {agent_url, format_id} and new parameterized format + # {agent_url, id, width?, height?, duration_ms?} formats_json = request.form.get("formats", "[]") or "[]" try: formats_parsed = json.loads(formats_json) if isinstance(formats_parsed, list) and formats_parsed: - # JSON format: [{"agent_url": "...", "format_id": "..."}] # Validate formats against creative agent registry from src.core.creative_agent_registry import get_creative_agent_registry @@ -1334,7 +1366,7 @@ def edit_product(tenant_id, product_id): registry = get_creative_agent_registry() available_formats = asyncio.run(registry.list_all_formats(tenant_id=tenant_id)) - # Build lookup of valid format IDs + # Build lookup of valid format IDs from live agent valid_format_ids = set() for fmt in available_formats: format_id_str = fmt.format_id.id if hasattr(fmt.format_id, "id") else str(fmt.format_id) @@ -1343,12 +1375,29 @@ def edit_product(tenant_id, product_id): validated_formats = [] invalid_formats = [] for fmt in formats_parsed: - if isinstance(fmt, dict) and fmt.get("agent_url") and fmt.get("format_id"): - format_id = fmt["format_id"] - if format_id in valid_format_ids: - validated_formats.append({"agent_url": fmt["agent_url"], "id": format_id}) - else: - invalid_formats.append(format_id) + if not isinstance(fmt, dict) or not fmt.get("agent_url"): + continue + + # Support both legacy {format_id} and new {id} field names + format_id = fmt.get("id") or fmt.get("format_id") + if not format_id: + continue + + if format_id in valid_format_ids: + # Build format dict with parameterized fields + format_entry = {"agent_url": fmt["agent_url"], "id": format_id} + + # Include optional dimension/duration parameters + if fmt.get("width") is not None: + format_entry["width"] = int(fmt["width"]) + if fmt.get("height") is not None: + format_entry["height"] = int(fmt["height"]) + if fmt.get("duration_ms") is not None: + format_entry["duration_ms"] = float(fmt["duration_ms"]) + + validated_formats.append(format_entry) + else: + invalid_formats.append(format_id) # Block save if any formats are invalid (registry confirmed they don't exist) if invalid_formats: @@ -1367,8 +1416,20 @@ def edit_product(tenant_id, product_id): logger.warning(f"Creative agent unreachable, saving formats without validation: {e}") validated_formats = [] for fmt in formats_parsed: - if isinstance(fmt, dict) and fmt.get("agent_url") and fmt.get("format_id"): - validated_formats.append({"agent_url": fmt["agent_url"], "id": fmt["format_id"]}) + if not isinstance(fmt, dict) or not fmt.get("agent_url"): + continue + format_id = fmt.get("id") or fmt.get("format_id") + if not format_id: + continue + + format_entry = {"agent_url": fmt["agent_url"], "id": format_id} + if fmt.get("width") is not None: + format_entry["width"] = int(fmt["width"]) + if fmt.get("height") is not None: + format_entry["height"] = int(fmt["height"]) + if fmt.get("duration_ms") is not None: + format_entry["duration_ms"] = float(fmt["duration_ms"]) + validated_formats.append(format_entry) flash( "Format validation unavailable (creative agent unreachable). " "Formats will be verified when creating media buys.", diff --git a/src/core/database/models.py b/src/core/database/models.py index 857cfae85..633b4adb8 100644 --- a/src/core/database/models.py +++ b/src/core/database/models.py @@ -610,6 +610,10 @@ class Creative(Base): # Data field stores creative content and metadata as JSON data: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) + # Format parameters for parameterized FormatId (AdCP 2.5 format templates) + # Stores width, height, duration_ms when format is parameterized + format_parameters: Mapped[dict | None] = mapped_column(JSONType, nullable=True) + # Relationships and metadata group_id: Mapped[str | None] = mapped_column(String(100), nullable=True) created_at: Mapped[datetime | None] = mapped_column( diff --git a/src/core/format_cache.py b/src/core/format_cache.py index 3da2ac6da..673676feb 100644 --- a/src/core/format_cache.py +++ b/src/core/format_cache.py @@ -14,6 +14,8 @@ import json from pathlib import Path +from adcp.types import FormatId as LibraryFormatId + from src.core.schemas import FormatId, url # Default agent URL for AdCP reference implementation @@ -79,10 +81,25 @@ def upgrade_legacy_format_id(format_id_value: str | dict | FormatId) -> FormatId >>> upgrade_legacy_format_id({"agent_url": "...", "id": "..."}) FormatId(agent_url="...", id="...") """ - # Already a FormatId object + # Already a FormatId object (check both our FormatId and library's FormatId) if isinstance(format_id_value, FormatId): return format_id_value + # Library FormatId (not our subclass) - convert to our FormatId + if isinstance(format_id_value, LibraryFormatId): + # Extract parameters for parameterized formats (AdCP 2.5) + kwargs = { + "agent_url": format_id_value.agent_url, + "id": format_id_value.id, + } + if format_id_value.width is not None: + kwargs["width"] = format_id_value.width + if format_id_value.height is not None: + kwargs["height"] = format_id_value.height + if format_id_value.duration_ms is not None: + kwargs["duration_ms"] = format_id_value.duration_ms + return FormatId(**kwargs) + # Already a dict with agent_url if isinstance(format_id_value, dict): if "agent_url" in format_id_value and "id" in format_id_value: diff --git a/src/core/helpers/__init__.py b/src/core/helpers/__init__.py index 7721a031b..59867e84b 100644 --- a/src/core/helpers/__init__.py +++ b/src/core/helpers/__init__.py @@ -11,8 +11,11 @@ from src.core.helpers.adapter_helpers import get_adapter from src.core.helpers.context_helpers import get_principal_id_from_context from src.core.helpers.creative_helpers import ( + FormatInfo, + FormatParameters, _convert_creative_to_adapter_asset, _detect_snippet_type, + _extract_format_info, _extract_format_namespace, _normalize_format_value, _validate_creative_assets, @@ -23,10 +26,13 @@ "get_adapter", "log_tool_activity", "get_principal_id_from_context", + "_extract_format_info", "_extract_format_namespace", "_normalize_format_value", "_validate_creative_assets", "_convert_creative_to_adapter_asset", "_detect_snippet_type", "validate_creative_format_against_product", + "FormatInfo", + "FormatParameters", ] diff --git a/src/core/helpers/creative_helpers.py b/src/core/helpers/creative_helpers.py index 2a5313489..525fb0c46 100644 --- a/src/core/helpers/creative_helpers.py +++ b/src/core/helpers/creative_helpers.py @@ -1,6 +1,6 @@ """Creative format parsing and asset conversion helpers.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict if TYPE_CHECKING: from fastmcp import Context @@ -13,6 +13,88 @@ from src.core.schemas import Creative +class FormatParameters(TypedDict, total=False): + """Optional format parameters for parameterized FormatId (AdCP 2.5).""" + + width: int + height: int + duration_ms: float + + +class FormatInfo(TypedDict): + """Complete format information extracted from FormatId.""" + + agent_url: str + format_id: str + parameters: FormatParameters | None + + +def _extract_format_info(format_value: Any) -> FormatInfo: + """Extract complete format information from format_id field (AdCP 2.5). + + Args: + format_value: FormatId dict/object with agent_url, id, and optional parameters + + Returns: + FormatInfo with agent_url, format_id, and optional parameters (width, height, duration_ms) + + Raises: + ValueError: If format_value doesn't have required agent_url and id fields + + Note: + This function supports parameterized format templates (AdCP 2.5). + Parameters are only included if they are present and non-None. + """ + agent_url: str + format_id: str + parameters: FormatParameters | None = None + + if isinstance(format_value, dict): + agent_url_val = format_value.get("agent_url") + format_id_val = format_value.get("id") + if not agent_url_val or not format_id_val: + raise ValueError(f"format_id must have both 'agent_url' and 'id' fields. Got: {format_value}") + agent_url = str(agent_url_val) + format_id = format_id_val + + # Extract optional parameters + params: FormatParameters = {} + if format_value.get("width") is not None: + params["width"] = int(format_value["width"]) + if format_value.get("height") is not None: + params["height"] = int(format_value["height"]) + if format_value.get("duration_ms") is not None: + params["duration_ms"] = float(format_value["duration_ms"]) + if params: + parameters = params + + elif hasattr(format_value, "agent_url") and hasattr(format_value, "id"): + agent_url = str(format_value.agent_url) + format_id = format_value.id + + # Extract optional parameters from object + params = {} + if getattr(format_value, "width", None) is not None: + params["width"] = int(format_value.width) + if getattr(format_value, "height", None) is not None: + params["height"] = int(format_value.height) + if getattr(format_value, "duration_ms", None) is not None: + params["duration_ms"] = float(format_value.duration_ms) + if params: + parameters = params + + elif isinstance(format_value, str): + raise ValueError( + f"format_id must be an object with 'agent_url' and 'id' fields (AdCP v2.4). " + f"Got string: '{format_value}'. " + f"String format_id is no longer supported - all formats must be namespaced." + ) + else: + raise ValueError(f"Invalid format_id format. Expected object with agent_url and id, got: {type(format_value)}") + + return {"agent_url": agent_url, "format_id": format_id, "parameters": parameters} + + def _extract_format_namespace(format_value: Any) -> tuple[str, str]: """Extract agent_url and format ID from format_id field (AdCP v2.4). @@ -121,6 +203,7 @@ def _convert_creative_to_adapter_asset(creative: Creative, package_assignments: """Convert AdCP v1 Creative object to format expected by ad server adapters. Extracts data from the assets dict to build adapter-compatible format. + Supports parameterized format templates (AdCP 2.5) for dimensions. """ # Base asset object with common fields @@ -135,6 +218,17 @@ def _convert_creative_to_adapter_asset(creative: Creative, package_assignments: "package_assignments": package_assignments, } + # Extract dimensions from FormatId parameters (AdCP 2.5 format templates) + # This is the primary source of truth for parameterized formats + format_id_obj = creative.format_id + if hasattr(format_id_obj, "width") and format_id_obj.width is not None: + asset["width"] = format_id_obj.width + if hasattr(format_id_obj, "height") and format_id_obj.height is not None: + asset["height"] = format_id_obj.height + if hasattr(format_id_obj, "duration_ms") and format_id_obj.duration_ms is not None: + # Convert to seconds for adapter compatibility + asset["duration"] = format_id_obj.duration_ms / 1000.0 + # Extract data from assets dict (AdCP v1 spec) assets_dict = creative.assets if isinstance(creative.assets, dict) else {} diff --git a/src/core/schemas.py b/src/core/schemas.py index 63859cdf5..092dc796b 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -686,19 +686,30 @@ def agent_url(self) -> str | None: return None def get_primary_dimensions(self) -> tuple[int, int] | None: - """Extract primary dimensions from renders array. + """Extract primary dimensions from renders array or format_id parameters. + + Checks in order: + 1. Parameterized format_id (AdCP 2.5) - width/height on FormatId + 2. Renders array - first render's dimensions + 3. Requirements field (legacy, internal) Returns: Tuple of (width, height) in pixels, or None if not available. """ - # Try renders field first (AdCP spec - renders is list of Render objects) + # Try format_id parameters first (AdCP 2.5 parameterized formats) + if hasattr(self.format_id, "get_dimensions"): + dims = self.format_id.get_dimensions() + if dims is not None: + return dims + + # Try renders field (AdCP spec - renders is list of Render objects) if self.renders and len(self.renders) > 0: primary_render = self.renders[0] # First render is typically primary if hasattr(primary_render, "dimensions") and primary_render.dimensions: - dims = primary_render.dimensions + render_dims = primary_render.dimensions # dimensions is a Dimensions object with width/height attributes - if dims.width is not None and dims.height is not None: - return (int(dims.width), int(dims.height)) + if render_dims.width is not None and render_dims.height is not None: + return (int(render_dims.width), int(render_dims.height)) # Fallback to requirements field (legacy, internal field) if self.requirements: @@ -1547,6 +1558,8 @@ class FormatId(LibraryFormatId): Note: The inherited agent_url field has type AnyUrl, but Pydantic accepts strings at runtime and automatically validates/converts them. This causes mypy warnings (str vs AnyUrl) which are safe to ignore - the code works correctly at runtime. + + AdCP 2.5+ supports parameterized format IDs with width/height/duration_ms fields. """ def __str__(self) -> str: @@ -1557,6 +1570,24 @@ def __repr__(self) -> str: """Return representation for debugging.""" return f"FormatId(id='{self.id}', agent_url='{self.agent_url}')" + def get_dimensions(self) -> tuple[int, int] | None: + """Get dimensions from parameterized FormatId (AdCP 2.5). + + Returns: + Tuple of (width, height) in pixels, or None if not specified. + """ + if self.width is not None and self.height is not None: + return (self.width, self.height) + return None + + def get_duration_ms(self) -> float | None: + """Get duration from parameterized FormatId (AdCP 2.5). + + Returns: + Duration in milliseconds, or None if not specified. + """ + return self.duration_ms + class Creative(LibraryCreative): """Individual creative asset - extends library Creative with customizations for our workflow. diff --git a/src/core/tools/creatives.py b/src/core/tools/creatives.py index 6ddb7893b..6042dd222 100644 --- a/src/core/tools/creatives.py +++ b/src/core/tools/creatives.py @@ -38,7 +38,7 @@ from src.core.config_loader import get_current_tenant from src.core.database.database_session import get_db_session from src.core.helpers import ( - _extract_format_namespace, + _extract_format_info, _validate_creative_assets, get_principal_id_from_context, log_tool_activity, @@ -307,11 +307,20 @@ def _sync_creatives_impl( if name_value is not None: existing_creative.name = str(name_value) changes.append("name") - # Use validated format_value (already auto-upgraded from string) - new_agent_url, new_format = _extract_format_namespace(format_value) - if new_agent_url != existing_creative.agent_url or new_format != existing_creative.format: + # Extract complete format info including parameters (AdCP 2.5) + format_info = _extract_format_info(format_value) + new_agent_url = format_info["agent_url"] + new_format = format_info["format_id"] + new_params = format_info["parameters"] + if ( + new_agent_url != existing_creative.agent_url + or new_format != existing_creative.format + or new_params != existing_creative.format_parameters + ): existing_creative.agent_url = new_agent_url existing_creative.format = new_format + # Cast TypedDict to dict for SQLAlchemy column type + existing_creative.format_parameters = cast(dict | None, new_params) changes.append("format") # Determine creative status based on approval mode @@ -1124,16 +1133,18 @@ def _sync_creatives_impl( creative_status = CreativeStatusEnum.pending_review.value needs_approval = False - # Extract agent_url and format ID from format_id field + # Extract complete format info including parameters (AdCP 2.5) # Use validated format_value (already auto-upgraded from string) - agent_url, format_id = _extract_format_namespace(format_value) + format_info = _extract_format_info(format_value) db_creative = DBCreative( tenant_id=tenant["tenant_id"], creative_id=creative.get("creative_id") or str(uuid.uuid4()), name=creative.get("name"), - agent_url=agent_url, - format=format_id, + agent_url=format_info["agent_url"], + format=format_info["format_id"], + # Cast TypedDict to dict for SQLAlchemy column type + format_parameters=cast(dict | None, format_info["parameters"]), principal_id=principal_id, status=creative_status, created_at=datetime.now(UTC), @@ -1967,10 +1978,22 @@ def _list_creatives_impl( # Build Creative directly with explicit types to satisfy mypy from src.core.schemas import FormatId, url - format_obj = FormatId( - agent_url=url(db_creative.agent_url), # agent_url is nullable=False in DB - id=db_creative.format or "", - ) + # Build FormatId with optional parameters (AdCP 2.5 format templates) + format_kwargs: dict[str, Any] = { + "agent_url": url(db_creative.agent_url), + "id": db_creative.format or "", + } + # Add format parameters if present + if db_creative.format_parameters: + params = db_creative.format_parameters + if "width" in params: + format_kwargs["width"] = params["width"] + if "height" in params: + format_kwargs["height"] = params["height"] + if "duration_ms" in params: + format_kwargs["duration_ms"] = params["duration_ms"] + + format_obj = FormatId(**format_kwargs) # Ensure datetime fields are datetime (not SQLAlchemy DateTime) created_at_dt: datetime = ( diff --git a/static/js/format-template-picker.js b/static/js/format-template-picker.js new file mode 100644 index 000000000..78f9e56ee --- /dev/null +++ b/static/js/format-template-picker.js @@ -0,0 +1,1042 @@ +/** + * Format Template Picker - AdCP 2.5 Parameterized Format Selection + * + * This module provides a reusable UI component for selecting creative format templates + * with configurable sizes (dimensions) instead of pre-defined format variations. + * + * Key features: + * - Template-based selection mapped to creative agent format IDs + * - Size parameters stored in FormatId: {agent_url, id, width, height, duration_ms} + * - Auto-populate sizes from inventory metadata + * - Backward compatible with legacy format_ids + * - Support for custom formats from tenant creative agents + * + * @see https://github.com/adcontextprotocol/salesagent/issues/782 + */ + +/** + * Escape HTML entities to prevent XSS attacks. + * @param {string} str - The string to escape + * @returns {string} - The escaped string safe for HTML interpolation + */ +function escapeHtml(str) { + if (typeof str !== 'string') return String(str); + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +/** + * Format templates mapped to creative agent format IDs. + * These IDs must match what the creative agent returns in list_creative_formats. + * + * Creative agent parameterized formats (from https://creative.adcontextprotocol.org): + * - display_image: accepts dimensions (width/height) - static images + * - display_html: accepts dimensions - HTML5 creatives + * - display_js: accepts dimensions - JavaScript creatives + * - display_generative: accepts dimensions - AI-generated + * - video_standard: accepts duration + * - video_vast: accepts duration - VAST redirect + * - video_dimensions: accepts dimensions + * - native_standard: no parameters + * + * Note: "display" and "video" templates are virtual templates that expand to + * multiple format types so products accept any creative variation. + * The actual creative type is auto-detected at upload time. + */ +const FORMAT_TEMPLATES = { + // Unified display template - expands to display_image, display_html, display_js + display: { + id: "display", // Virtual ID - expands to multiple format IDs + name: "Display", + description: "Display banner ads (image, HTML5, or JavaScript - auto-detected at upload)", + type: "display", + parameterType: "dimensions", // width/height + // Maps to these actual creative agent format IDs + expandsTo: ["display_image", "display_html", "display_js"], + commonSizes: [ + { width: 300, height: 250, name: "Medium Rectangle" }, + { width: 728, height: 90, name: "Leaderboard" }, + { width: 160, height: 600, name: "Wide Skyscraper" }, + { width: 300, height: 600, name: "Half Page" }, + { width: 320, height: 50, name: "Mobile Banner" }, + { width: 970, height: 250, name: "Billboard" }, + { width: 336, height: 280, name: "Large Rectangle" }, + { width: 468, height: 60, name: "Banner" }, + { width: 320, height: 100, name: "Large Mobile Banner" }, + { width: 970, height: 90, name: "Large Leaderboard" } + ], + gamSupported: true + }, + // Unified video template - expands to video_standard, video_vast + video: { + id: "video", // Virtual ID - expands to multiple format IDs + name: "Video", + description: "Video ads (hosted or VAST - auto-detected at upload)", + type: "video", + parameterType: "duration", // duration_ms parameter + // Maps to these actual creative agent format IDs + expandsTo: ["video_standard", "video_vast"], + commonDurations: [ + { ms: 6000, name: "6s (Bumper)" }, + { ms: 15000, name: "15s (Standard)" }, + { ms: 30000, name: "30s (Standard)" }, + { ms: 60000, name: "60s (Long)" } + ], + gamSupported: true + }, + native_standard: { + id: "native_standard", + name: "Native", + description: "Native content ads that match the look of the site", + type: "native", + parameterType: "none", // No dimension parameters + gamSupported: true + } +}; + +/** + * Default creative agent URL for AdCP reference formats. + */ +const DEFAULT_CREATIVE_AGENT_URL = "https://creative.adcontextprotocol.org"; + +/** + * Format Template Picker Component + * + * Usage: + * const picker = new FormatTemplatePicker({ + * containerId: 'format-picker-container', + * hiddenInputId: 'formats-data', + * tenantId: 'tenant_123', + * adapterType: 'gam', // or 'mock' + * initialFormats: [], // Existing format_ids for edit mode + * onSelectionChange: (formats) => console.log('Selected:', formats) + * }); + */ +class FormatTemplatePicker { + constructor(options) { + this.containerId = options.containerId; + this.hiddenInputId = options.hiddenInputId; + this.tenantId = options.tenantId; + this.adapterType = options.adapterType || 'mock'; + this.scriptRoot = options.scriptRoot || ''; + this.onSelectionChange = options.onSelectionChange || (() => {}); + + // State + this.selectedTemplates = new Map(); // templateId -> Set of {width, height} or {duration_ms} + this.customFormats = []; // Legacy/custom formats: [{agent_url, id, width?, height?}] + this.inventorySizes = new Set(); // Sizes from inventory: "300x250", "728x90" + this.agentFormats = {}; // Formats from creative agents: {agent_url: [Format, ...]} + this.agentsLoaded = false; // Track if agent formats have been loaded + + // Initialize + this.container = document.getElementById(this.containerId); + this.hiddenInput = document.getElementById(this.hiddenInputId); + + if (!this.container) { + console.error(`[FormatTemplatePicker] Container #${this.containerId} not found`); + return; + } + + // Load initial formats if provided (edit mode) + if (options.initialFormats && options.initialFormats.length > 0) { + this._parseInitialFormats(options.initialFormats); + // Update hidden input with initial formats so form submission works + this._updateHiddenInput(); + } + + this.render(); + this._loadAgentFormats(); + } + + /** + * Parse existing format_ids into template selections. + * Handles both new parameterized formats and legacy formats. + */ + _parseInitialFormats(formats) { + for (const fmt of formats) { + const id = fmt.id || fmt.format_id; + const agentUrl = fmt.agent_url || DEFAULT_CREATIVE_AGENT_URL; + + // Check if this is a known template + if (FORMAT_TEMPLATES[id]) { + if (!this.selectedTemplates.has(id)) { + this.selectedTemplates.set(id, new Set()); + } + + // Add size/duration if present + if (fmt.width && fmt.height) { + this.selectedTemplates.get(id).add(`${fmt.width}x${fmt.height}`); + } + if (fmt.duration_ms) { + this.selectedTemplates.get(id).add(`d:${fmt.duration_ms}`); + } + } else { + // Legacy or custom format + this.customFormats.push({ + agent_url: agentUrl, + id: id, + width: fmt.width, + height: fmt.height, + duration_ms: fmt.duration_ms + }); + } + } + } + + /** + * Load formats from creative agents for the dropdown selection. + * Fetches from /api/formats/list which returns all formats from tenant's creative agents. + */ + async _loadAgentFormats() { + try { + const response = await fetch(`${this.scriptRoot}/api/formats/list?tenant_id=${encodeURIComponent(this.tenantId)}`); + if (!response.ok) { + console.warn('[FormatTemplatePicker] HTTP error loading agent formats:', response.status); + return; + } + const data = await response.json(); + + if (data.error) { + console.warn('[FormatTemplatePicker] Error loading agent formats:', data.error); + return; + } + + // Store formats grouped by agent + this.agentFormats = data.agents || {}; + this.agentsLoaded = true; + + // Log what we got + const totalFormats = Object.values(this.agentFormats).reduce((sum, fmts) => sum + fmts.length, 0); + const agentCount = Object.keys(this.agentFormats).length; + console.log(`[FormatTemplatePicker] Loaded ${totalFormats} formats from ${agentCount} creative agent(s)`); + + // Re-render to show the dropdown + this.render(); + } catch (error) { + console.error('[FormatTemplatePicker] Failed to load agent formats:', error); + } + } + + /** + * Add sizes from inventory metadata. + * Called when user selects ad units/placements from inventory. + */ + addSizesFromInventory(sizes) { + if (!sizes || !Array.isArray(sizes)) return; + + sizes.forEach(sizeStr => { + if (typeof sizeStr === 'string' && sizeStr.includes('x')) { + this.inventorySizes.add(sizeStr); + + // Auto-select the unified "display" template and add these sizes + // This template expands to display_image, display_html, display_js + if (!this.selectedTemplates.has('display')) { + this.selectedTemplates.set('display', new Set()); + } + this.selectedTemplates.get('display').add(sizeStr); + } + }); + + this.render(); + this._updateHiddenInput(); + } + + /** + * Clear inventory-derived sizes. + */ + clearInventorySizes() { + // Remove inventory sizes from selections + for (const sizeStr of this.inventorySizes) { + for (const [templateId, sizes] of this.selectedTemplates) { + sizes.delete(sizeStr); + } + } + this.inventorySizes.clear(); + this.render(); + this._updateHiddenInput(); + } + + /** + * Remove a single inventory size. + */ + removeInventorySize(sizeStr) { + if (!this.inventorySizes.has(sizeStr)) return; + + // Remove from inventory sizes set + this.inventorySizes.delete(sizeStr); + + // Remove from all template selections + for (const [templateId, sizes] of this.selectedTemplates) { + sizes.delete(sizeStr); + } + + this.render(); + this._updateHiddenInput(); + } + + /** + * Toggle a template selection. + */ + toggleTemplate(templateId) { + if (this.selectedTemplates.has(templateId)) { + this.selectedTemplates.delete(templateId); + } else { + this.selectedTemplates.set(templateId, new Set()); + } + this.render(); + this._updateHiddenInput(); + } + + /** + * Toggle a size for a template. + */ + toggleSize(templateId, width, height) { + const sizeKey = `${width}x${height}`; + + if (!this.selectedTemplates.has(templateId)) { + this.selectedTemplates.set(templateId, new Set()); + } + + const sizes = this.selectedTemplates.get(templateId); + if (sizes.has(sizeKey)) { + sizes.delete(sizeKey); + } else { + sizes.add(sizeKey); + } + + this.render(); + this._updateHiddenInput(); + } + + /** + * Toggle a duration for a template. + */ + toggleDuration(templateId, durationMs) { + const durationKey = `d:${durationMs}`; + + if (!this.selectedTemplates.has(templateId)) { + this.selectedTemplates.set(templateId, new Set()); + } + + const params = this.selectedTemplates.get(templateId); + if (params.has(durationKey)) { + params.delete(durationKey); + } else { + params.add(durationKey); + } + + this.render(); + this._updateHiddenInput(); + } + + /** + * Add a custom size to a template. + */ + addCustomSize(templateId, width, height) { + if (!width || !height || width <= 0 || height <= 0) { + alert('Please enter valid width and height values.'); + return; + } + + const sizeKey = `${width}x${height}`; + + if (!this.selectedTemplates.has(templateId)) { + this.selectedTemplates.set(templateId, new Set()); + } + + this.selectedTemplates.get(templateId).add(sizeKey); + this.render(); + this._updateHiddenInput(); + } + + /** + * Add a custom/agent format. + */ + addCustomFormat(agentUrl, formatId, width, height, durationMs) { + if (!agentUrl || !formatId) { + alert('Agent URL and Format ID are required.'); + return; + } + + this.customFormats.push({ + agent_url: agentUrl, + id: formatId, + width: width || null, + height: height || null, + duration_ms: durationMs || null + }); + + this.render(); + this._updateHiddenInput(); + } + + /** + * Remove a custom format. + */ + removeCustomFormat(index) { + this.customFormats.splice(index, 1); + this.render(); + this._updateHiddenInput(); + } + + /** + * Get all selected formats as FormatId objects. + * + * Templates with `expandsTo` property (like "display") will emit multiple + * format IDs for each size, so products accept any creative type. + */ + getSelectedFormats() { + const formats = []; + + // Template-based formats + for (const [templateId, params] of this.selectedTemplates) { + const template = FORMAT_TEMPLATES[templateId]; + if (!template) continue; + + // Get the actual format IDs to emit + // Templates with expandsTo emit multiple format IDs per size + const formatIds = template.expandsTo || [templateId]; + + if (params.size === 0) { + // Template selected but no sizes - include without params + for (const fmtId of formatIds) { + formats.push({ + agent_url: DEFAULT_CREATIVE_AGENT_URL, + id: fmtId + }); + } + } else { + // Include each size/duration as separate format + for (const paramKey of params) { + // For each parameter (size/duration), emit all format IDs + for (const fmtId of formatIds) { + const format = { + agent_url: DEFAULT_CREATIVE_AGENT_URL, + id: fmtId + }; + + if (paramKey.startsWith('d:')) { + format.duration_ms = parseInt(paramKey.substring(2), 10); + } else if (paramKey.includes('x')) { + const [w, h] = paramKey.split('x').map(Number); + format.width = w; + format.height = h; + } + + formats.push(format); + } + } + } + } + + // Custom/legacy formats + for (const customFmt of this.customFormats) { + formats.push({ + agent_url: customFmt.agent_url, + id: customFmt.id, + width: customFmt.width || undefined, + height: customFmt.height || undefined, + duration_ms: customFmt.duration_ms || undefined + }); + } + + return formats; + } + + /** + * Update the hidden input with selected formats. + */ + _updateHiddenInput() { + const formats = this.getSelectedFormats(); + this.hiddenInput.value = JSON.stringify(formats); + this.onSelectionChange(formats); + } + + /** + * Render the picker UI. + */ + render() { + const isGAM = this.adapterType === 'gam'; + + // Filter templates for GAM (no audio) + const availableTemplates = Object.values(FORMAT_TEMPLATES).filter(t => + !isGAM || t.gamSupported + ); + + let html = ` +
+ +
+

+ Standard Format Templates +

+
+ `; + + // Render template cards + for (const template of availableTemplates) { + const isSelected = this.selectedTemplates.has(template.id); + const selectedParams = this.selectedTemplates.get(template.id) || new Set(); + const sizeCount = [...selectedParams].filter(p => !p.startsWith('d:')).length; + const durationCount = [...selectedParams].filter(p => p.startsWith('d:')).length; + + html += this._renderTemplateCard(template, isSelected, sizeCount, durationCount); + } + + html += ` +
+
+ `; + + // Size configuration for selected templates + for (const [templateId, params] of this.selectedTemplates) { + const template = FORMAT_TEMPLATES[templateId]; + if (!template) continue; + + html += this._renderSizeConfiguration(template, params); + } + + // Inventory sizes section (if any) + if (this.inventorySizes.size > 0) { + html += this._renderInventorySizesSection(); + } + + // Custom formats section + html += this._renderCustomFormatsSection(); + + // Selected formats summary + html += this._renderSelectedSummary(); + + html += `
`; + + this.container.innerHTML = html; + this._attachEventListeners(); + } + + _renderTemplateCard(template, isSelected, sizeCount, durationCount) { + const borderColor = isSelected ? '#0066cc' : '#e0e0e0'; + const bgColor = isSelected ? '#f0f7ff' : '#f9f9f9'; + const checkIcon = isSelected ? '✓ ' : ''; + + let paramInfo = ''; + if (isSelected) { + if (sizeCount > 0) paramInfo += `${sizeCount} size${sizeCount > 1 ? 's' : ''}`; + if (durationCount > 0) { + if (paramInfo) paramInfo += ', '; + paramInfo += `${durationCount} duration${durationCount > 1 ? 's' : ''}`; + } + } + + return ` +
+
+ ${checkIcon}${template.name} +
+
+ ${template.description} +
+
+ + ${template.type} + + ${paramInfo ? `${paramInfo}` : ''} +
+
+ `; + } + + _renderSizeConfiguration(template, selectedParams) { + let html = ` +
+
+ Configure: ${template.name} +
+ `; + + // Common sizes (quick-pick buttons) + if (template.commonSizes && template.commonSizes.length > 0) { + html += ` +
+ +
+ `; + + for (const size of template.commonSizes) { + const sizeKey = `${size.width}x${size.height}`; + const isSelected = selectedParams.has(sizeKey); + const isFromInventory = this.inventorySizes.has(sizeKey); + const bgColor = isSelected ? '#0066cc' : '#fff'; + const textColor = isSelected ? '#fff' : '#333'; + const border = isFromInventory ? '2px solid #28a745' : '1px solid #ddd'; + + html += ` + + `; + } + + html += `
`; + } + + // Common durations for video/audio + if (template.commonDurations && template.commonDurations.length > 0) { + html += ` +
+ +
+ `; + + for (const dur of template.commonDurations) { + const durKey = `d:${dur.ms}`; + const isSelected = selectedParams.has(durKey); + const bgColor = isSelected ? '#0066cc' : '#fff'; + const textColor = isSelected ? '#fff' : '#333'; + + html += ` + + `; + } + + html += `
`; + } + + // Custom size input + if (template.parameterType === 'dimensions' || template.parameterType === 'both') { + html += ` +
+ +
+ + x + + +
+
+ `; + } + + // Selected sizes as removable tags + const selectedSizes = [...selectedParams].filter(p => !p.startsWith('d:')); + if (selectedSizes.length > 0) { + html += ` +
+ +
+ `; + + for (const sizeKey of selectedSizes) { + const isFromInventory = this.inventorySizes.has(sizeKey); + const escapedSizeKey = escapeHtml(sizeKey); + html += ` + + ${escapedSizeKey} + ${isFromInventory ? '📦' : ''} + × + + `; + } + + html += `
`; + } + + html += `
`; + return html; + } + + _renderInventorySizesSection() { + const sizes = [...this.inventorySizes]; + + return ` +
+
+
+ 📦 Sizes from Inventory +
+ +
+

+ These sizes were automatically added based on your selected inventory. Click × to remove. +

+
+ ${sizes.map(s => { + const escaped = escapeHtml(s); + return ` + + ${escaped} + × + + `;}).join('')} +
+
+ `; + } + + _renderCustomFormatsSection() { + // Build dropdown options from creative agent formats + const hasAgentFormats = this.agentsLoaded && Object.keys(this.agentFormats).length > 0; + + let html = ` +
+
+ Additional Formats from Creative Agents +
+

+ ${hasAgentFormats + ? 'Select formats from your registered creative agents. These are parameterized formats that work with your ad server.' + : 'Loading formats from creative agents...'} +

+ `; + + // Existing custom formats + if (this.customFormats.length > 0) { + html += `
`; + for (let i = 0; i < this.customFormats.length; i++) { + const fmt = this.customFormats[i]; + let label = fmt.id; + if (fmt.width && fmt.height) label += ` (${fmt.width}x${fmt.height})`; + if (fmt.duration_ms) label += ` (${fmt.duration_ms / 1000}s)`; + html += ` + + ${label} + × + + `; + } + html += `
`; + } + + // Format selection dropdown (only if formats are loaded) + if (hasAgentFormats) { + html += ` +
+
+ + +
+
+ +
+
+ `; + + // Show parameter inputs for parameterized formats + html += ` + + `; + } + + html += `
`; + return html; + } + + _renderSelectedSummary() { + const formats = this.getSelectedFormats(); + + if (formats.length === 0) { + return ` +
+ No formats selected. Select at least one format template or add a custom format. +
+ `; + } + + let html = ` +
+
+ ✓ ${formats.length} Format${formats.length > 1 ? 's' : ''} Selected +
+
+ `; + + for (const fmt of formats) { + let label = fmt.id; + if (fmt.width && fmt.height) { + label += ` (${fmt.width}x${fmt.height})`; + } + if (fmt.duration_ms) { + label += ` (${fmt.duration_ms / 1000}s)`; + } + + html += ` + + ${label} + + `; + } + + html += `
`; + return html; + } + + /** + * Attach event listeners after rendering. + */ + _attachEventListeners() { + // Template card clicks + this.container.querySelectorAll('.template-card').forEach(card => { + card.addEventListener('click', () => { + this.toggleTemplate(card.dataset.templateId); + }); + }); + + // Size button clicks + this.container.querySelectorAll('.size-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleSize(btn.dataset.templateId, parseInt(btn.dataset.width), parseInt(btn.dataset.height)); + }); + }); + + // Duration button clicks + this.container.querySelectorAll('.duration-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleDuration(btn.dataset.templateId, parseInt(btn.dataset.durationMs)); + }); + }); + + // Remove size clicks + this.container.querySelectorAll('.remove-size').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const [w, h] = btn.dataset.size.split('x').map(Number); + this.toggleSize(btn.dataset.templateId, w, h); + }); + }); + + // Add custom size + this.container.querySelectorAll('.add-custom-size-btn').forEach(btn => { + btn.addEventListener('click', () => { + const templateId = btn.dataset.templateId; + const widthInput = this.container.querySelector(`.custom-width[data-template-id="${templateId}"]`); + const heightInput = this.container.querySelector(`.custom-height[data-template-id="${templateId}"]`); + this.addCustomSize(templateId, parseInt(widthInput.value), parseInt(heightInput.value)); + widthInput.value = ''; + heightInput.value = ''; + }); + }); + + // Remove inventory size (individual) + this.container.querySelectorAll('.remove-inventory-size').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeInventorySize(btn.dataset.size); + }); + }); + + // Clear all inventory sizes + const clearInventoryBtn = this.container.querySelector('.clear-inventory-sizes-btn'); + if (clearInventoryBtn) { + clearInventoryBtn.addEventListener('click', () => { + this.clearInventorySizes(); + }); + } + + // Remove custom format + this.container.querySelectorAll('.remove-custom').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeCustomFormat(parseInt(btn.dataset.index)); + }); + }); + + // Add agent format button (dropdown-based) + const addAgentFormatBtn = this.container.querySelector('#add-agent-format-btn'); + if (addAgentFormatBtn) { + addAgentFormatBtn.addEventListener('click', () => { + const select = this.container.querySelector('#agent-format-select'); + const selectedOption = select.options[select.selectedIndex]; + + if (!selectedOption || !selectedOption.value) { + alert('Please select a format from the dropdown.'); + return; + } + + // Parse format data from data attribute + let formatData; + try { + formatData = JSON.parse(selectedOption.dataset.format); + } catch (e) { + console.error('[FormatTemplatePicker] Invalid format data:', e); + alert('Invalid format selected. Please try again.'); + return; + } + + // Get optional parameters + const width = parseInt(this.container.querySelector('#param-width')?.value) || null; + const height = parseInt(this.container.querySelector('#param-height')?.value) || null; + const durationMs = parseInt(this.container.querySelector('#param-duration')?.value) || null; + + this.addCustomFormat( + formatData.agent_url, + formatData.id, + width, + height, + durationMs + ); + + // Reset form + select.value = ''; + const paramsSection = this.container.querySelector('#format-params-section'); + if (paramsSection) paramsSection.style.display = 'none'; + const widthInput = this.container.querySelector('#param-width'); + const heightInput = this.container.querySelector('#param-height'); + const durationSelect = this.container.querySelector('#param-duration'); + if (widthInput) widthInput.value = ''; + if (heightInput) heightInput.value = ''; + if (durationSelect) durationSelect.value = ''; + }); + } + + // Format select change handler - show/hide parameter inputs + const formatSelect = this.container.querySelector('#agent-format-select'); + if (formatSelect) { + formatSelect.addEventListener('change', () => { + const selectedOption = formatSelect.options[formatSelect.selectedIndex]; + const paramsSection = this.container.querySelector('#format-params-section'); + const dimsParams = this.container.querySelector('#dims-params'); + const durationParams = this.container.querySelector('#duration-params'); + + if (!selectedOption || !selectedOption.value) { + if (paramsSection) paramsSection.style.display = 'none'; + return; + } + + let formatData; + try { + formatData = JSON.parse(selectedOption.dataset.format); + } catch (e) { + console.error('[FormatTemplatePicker] Invalid format data:', e); + if (paramsSection) paramsSection.style.display = 'none'; + return; + } + const formatType = formatData.type || ''; + + // Determine which params apply to this format type + const showDims = formatType === 'display' || formatType === 'video'; + const showDuration = formatType === 'video' || formatType === 'audio'; + + // Only show params section if at least one param type is relevant + if (paramsSection) { + paramsSection.style.display = (showDims || showDuration) ? 'block' : 'none'; + } + + // Show appropriate parameter inputs based on format type + if (dimsParams) { + dimsParams.style.display = showDims ? 'block' : 'none'; + } + if (durationParams) { + durationParams.style.display = showDuration ? 'block' : 'none'; + } + }); + } + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + FORMAT_TEMPLATES, + DEFAULT_CREATIVE_AGENT_URL, + FormatTemplatePicker + }; +} diff --git a/templates/add_product_gam.html b/templates/add_product_gam.html index 4418078c5..07eddc728 100644 --- a/templates/add_product_gam.html +++ b/templates/add_product_gam.html @@ -364,85 +364,25 @@

GAM Inventory

Creative Formats

-

Choose creative formats for this product. Click the ⓘ icon on each format to learn more.

- - - +

+ Select format templates and configure sizes. Creative type (image, JS, HTML5) is auto-detected when advertisers upload creatives. +
Note: Audio formats are not supported by Google Ad Manager. +

- - + + +

Pricing Options

@@ -699,15 +639,7 @@

// Helper function to recursively extract sizes from an ad unit and its children const extractSizesFromUnit = (unitId) => { const item = inventoryCache.adUnits.get(unitId); - if (!item) { - console.log('extractSizesFromUnit: unit not found in cache:', unitId); - return; - } - - console.log('extractSizesFromUnit: processing unit', unitId, item); - console.log('extractSizesFromUnit: item.children =', item.children); - console.log('extractSizesFromUnit: item.sizes =', item.sizes); - console.log('extractSizesFromUnit: item.metadata?.sizes =', item.metadata?.sizes); + if (!item) return; // Extract sizes from this unit using helper function // Check both item.sizes (raw API format) and item.metadata.sizes (normalized format) @@ -719,9 +651,7 @@

// Recursively extract sizes from children (for hierarchical ad units) // Children are stored as full objects from the tree API if (item.children && Array.isArray(item.children)) { - console.log('extractSizesFromUnit: found', item.children.length, 'children'); item.children.forEach(child => { - console.log('extractSizesFromUnit: processing child', child.id, 'sizes:', child.sizes); // Child has sizes directly from tree API - use helper function addSizesToCache(child.sizes); // Recursively process grandchildren if they exist @@ -795,9 +725,16 @@

} } - // Update UI + // Update legacy UI (size chips panel) updateSizeChipsPanel(); updateFormatMatchIndicators(); + + // Pass extracted sizes to format template picker (AdCP 2.5 parameterized formats) + if (window.formatPickerIntegration && inventoryCache && inventoryCache.extractedSizes.size > 0) { + const sizesArray = Array.from(inventoryCache.extractedSizes); + console.log('[FormatPicker] Auto-selecting sizes from inventory:', sizesArray); + window.formatPickerIntegration.addSizesFromInventory(sizesArray); + } } /** @@ -806,8 +743,11 @@

function updateSizeChipsPanel() { const panel = document.getElementById('size-filter-panel'); + // Early return if panel doesn't exist (e.g., when using format template picker instead) + if (!panel) return; + if (!inventoryCache || inventoryCache.extractedSizes.size === 0) { - if (panel) panel.style.display = 'none'; + panel.style.display = 'none'; return; } @@ -822,6 +762,8 @@

}); const chipsContainer = document.getElementById('size-chips-container'); + if (!chipsContainer) return; + chipsContainer.innerHTML = sortedSizes.map(sizeKey => { const count = inventoryCache.sizeUnitCounts.get(sizeKey) || 0; const displaySize = sizeKey.replace('x', '×'); @@ -1992,25 +1934,41 @@

Pricing Option #${index + 1}
// Populate formats JSON before form submission function prepareFormSubmission(event) { - // Convert checked format checkboxes to JSON for backend - const formatCheckboxes = document.querySelectorAll('.format-checkbox:checked'); - const formatsJson = Array.from(formatCheckboxes).map(cb => ({ - agent_url: cb.dataset.agentUrl || '', - format_id: cb.dataset.formatId || '' - })); - - // Populate the hidden field with JSON + // Check if format picker has already populated the field const formatsField = document.getElementById('formats-data'); - if (formatsField) { - formatsField.value = JSON.stringify(formatsJson); + const existingValue = formatsField ? formatsField.value : ''; + let hasFormatPickerData = false; + + try { + const parsed = JSON.parse(existingValue); + hasFormatPickerData = Array.isArray(parsed) && parsed.length > 0; + } catch (e) { + hasFormatPickerData = false; } - console.log('[DEBUG] Form submission - formats JSON:', formatsJson); - console.log('[DEBUG] Form submission - total checked:', formatsJson.length); + if (hasFormatPickerData) { + // Format picker already populated - don't overwrite + console.log('[DEBUG] Form submission - using format picker data:', existingValue); + } else { + // Legacy path: Convert checked format checkboxes to JSON for backend + const formatCheckboxes = document.querySelectorAll('.format-checkbox:checked'); + const formatsJson = Array.from(formatCheckboxes).map(cb => ({ + agent_url: cb.dataset.agentUrl || '', + format_id: cb.dataset.formatId || '' + })); + + // Populate the hidden field with JSON + if (formatsField) { + formatsField.value = JSON.stringify(formatsJson); + } + + console.log('[DEBUG] Form submission - formats JSON (legacy):', formatsJson); + console.log('[DEBUG] Form submission - total checked:', formatsJson.length); - if (formatsJson.length === 0) { - console.log('[DEBUG] NO FORMATS CHECKED ON SUBMIT'); - // Don't prevent submission, just log + if (formatsJson.length === 0) { + console.log('[DEBUG] NO FORMATS CHECKED ON SUBMIT'); + // Don't prevent submission, just log + } } // Debug targeting data @@ -2384,6 +2342,40 @@
Pricing Option #${index + 1}
addPricingOption(); {% endif %} +// Initialize format template picker +// Use window.formatPicker for global access (needed for form submission and testing) +(function() { + // Get existing formats from product (edit mode) or form_data (validation error) + const existingFormats = {% if product and product.formats %}{{ product.formats | tojson }}{% elif form_data and form_data.formats %}{{ form_data.formats | tojson }}{% else %}[]{% endif %}; + + window.formatPicker = new FormatTemplatePicker({ + containerId: 'format-template-picker-container', + hiddenInputId: 'formats-data', + tenantId: '{{ tenant_id }}', + adapterType: 'gam', // GAM adapter - hides audio formats + scriptRoot: '{{ request.script_root }}' || '', + initialFormats: existingFormats, + onSelectionChange: function(formats) { + console.log('Formats updated:', formats.length, 'formats selected'); + } + }); + + // Integration with inventory picker: auto-populate sizes from selected inventory + // Hook into inventory selection callbacks to extract sizes + window.formatPickerIntegration = { + addSizesFromInventory: function(sizes) { + if (formatPicker && sizes && sizes.length > 0) { + formatPicker.addSizesFromInventory(sizes); + } + }, + clearInventorySizes: function() { + if (formatPicker) { + formatPicker.clearInventorySizes(); + } + } + }; +})(); + // Format info modal functionality function showFormatInfo(name, description, previewUrl, agentUrl, formatId) { const modal = document.getElementById('format-info-modal'); diff --git a/templates/add_product_mock.html b/templates/add_product_mock.html index c3fa2ef94..cd5c129d6 100644 --- a/templates/add_product_mock.html +++ b/templates/add_product_mock.html @@ -38,359 +38,40 @@

Add New Product

Creative Formats

- - - -
- - - 💡 Tip: Search by dimensions (300x250), type (video, display), or keyword (generative) - -
- - -
- ⚠️ Note: Native ad formats (1×1) are not yet supported. Standard display, video, and audio formats work with all ad server sizes. - Track native support progress → -
- - - - - -
-
- Loading formats from creative agents... + +

+ Choose format templates and specify which sizes this product supports. Creative type (image, JS, HTML5) + is auto-detected when advertisers upload creatives. +

+ + +
+
+ Loading format templates...
-
- - - + + diff --git a/templates/edit_product_mock.html b/templates/edit_product_mock.html index 88b2793b7..f44af10dd 100644 --- a/templates/edit_product_mock.html +++ b/templates/edit_product_mock.html @@ -105,62 +105,25 @@

Pricing Options (Recommended)<

Creative Formats

- - - -
- - - 💡 Tip: Search by dimensions (300x250), type (video, display), or keyword (generative) - -
- - -
- ⚠️ Note: Native ad formats (1×1) are not yet supported. Standard display, video, and audio formats work with all ad server sizes. - Track native support progress → -
- - -