Video Schema Framework
Use conditional-logic schema generation to keep Video markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralVideo documentationUse on watch pages where the video is the main content and users can play it. Key moments (Clip or SeekToAction) and the LIVE badge (BroadcastEvent) are added only when the source supports them.
Key Implementation Documentation Highlights
- Required properties: name, thumbnailUrl, uploadDate.
- Recommended properties: contentUrl, embedUrl, description, duration, creator (or author), expires, hasPart, interactionStatistic, publication, regionsAllowed or ineligibleRegion.
- Provide contentUrl (the most effective way for Google to fetch the video file) or embedUrl (the player URL). Neither may be the watch page, and contentUrl must point to a supported video file type, never a data URL.
- uploadDate and expires are ISO 8601 date-times with timezone; without one Google uses Googlebot's timezone. duration is an ISO 8601 duration such as
PT12M34S. Only add expires if the video expires. - Thumbnails must be at least 60x30 pixels, crawlable by Googlebot and Googlebot-Image, and served from a stable URL. If creator or author is set, it needs name or alternateName.
- regionsAllowed and ineligibleRegion take ISO 3166-1 alpha-2 or alpha-3 country codes; use one or the other, not both. interactionStatistic uses WatchAction, LikeAction, CommentAction or ShareAction with an integer userInteractionCount.
- Key moments: Clip needs name, startOffset, url (endOffset recommended); the clip url must be the video's URL path plus a time parameter, and no two clips may share a startOffset. SeekToAction needs target containing
{seek_to_second_number}and startOffset-input set torequired name=seek_to_second_number. The video must be at least 30 seconds long and deep linkable. - LIVE badge: publication BroadcastEvent needs isLiveBroadcast (true), startDate and endDate. endDate is required once the stream ends; give an approximate one in advance. Notify Google with the Indexing API when the stream starts, ends and whenever the markup changes.
- Videos can appear in the main results, Video mode, Google Images and Discover. Validate with the Rich Results Test and monitor the Video indexing report in Search Console.
Template Approach
const pageData = {
title: "How to Shape a Sourdough Boule",
description: "A baker walks through pre-shaping, bench rest and final shaping for a round sourdough loaf.",
thumbnail: "https://www.example.com/media/shaping-sourdough-16x9.jpg",
uploadDate: "2026-03-14T08:00:00-05:00",
duration: "PT12M34S",
contentUrl: "https://cdn.example.com/video/shaping-sourdough.mp4"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": pageData.title,
"description": pageData.description,
"thumbnailUrl": [pageData.thumbnail],
"uploadDate": pageData.uploadDate,
"duration": pageData.duration,
"contentUrl": pageData.contentUrl
};
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "How to Shape a Sourdough Boule",
"description": "A baker walks through pre-shaping, bench rest and final shaping for a round sourdough loaf.",
"thumbnailUrl": [
"https://www.example.com/media/shaping-sourdough-16x9.jpg"
],
"uploadDate": "2026-03-14T08:00:00-05:00",
"duration": "PT12M34S",
"contentUrl": "https://cdn.example.com/video/shaping-sourdough.mp4"
}
Conditional-logic Framework
// Shared helpers
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATETIME = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function toIsoDate(value) {
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
if (!match) return null;
const [y, m, d] = match.slice(1).map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null;
}
// Keeps the original timezone offset; rejects naive or unparseable values.
function toIsoDateTime(value) {
const match = typeof value === "string" ? ISO_DATETIME.exec(value) : null;
return match && toIsoDate(match[1]) ? value : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
// Page gates: indexable, self-canonical, marked-up content visible.
function passesPageGates(page) {
if (!page || page.noindex || page.robotsBlocked) return false;
if (!isAbsoluteUrl(page.url)) return false;
if (page.canonicalUrl && page.canonicalUrl !== page.url) return false;
return page.contentVisible === true;
}
// Video helpers
const REGION_CODE = /^[A-Z]{2,3}$/;
const VIDEO_FILE = /\.(3gp|3g2|asf|avi|divx|m2v|m3u|m3u8|m4v|mkv|mov|mp4|mpeg|ogv|qvt|ram|rm|vob|webm|wmv|xap)$/i;
// Seconds to ISO 8601 duration, e.g. 754 -> "PT12M34S".
function toIsoDuration(seconds) {
if (!Number.isInteger(seconds) || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return "PT" + (h ? h + "H" : "") + (m ? m + "M" : "") + (s ? s + "S" : "");
}
function withTimeParam(pageUrl, value) {
return pageUrl + (pageUrl.includes("?") ? "&" : "?") + "t=" + value;
}
function buildParty(party) {
if (!party || !party.name) return null; // creator/author needs name
return {
"@type": party.type === "Person" ? "Person" : "Organization",
name: party.name,
url: isAbsoluteUrl(party.url) ? party.url : null
};
}
// Key moments, option 1: Clip per chapter (video must be 30s or longer).
function buildClips(chapters, durationSeconds, pageUrl) {
if (!Number.isInteger(durationSeconds) || durationSeconds < 30) return [];
const valid = (chapters || [])
.filter((c) => c && c.name && Number.isInteger(c.startSeconds) && c.startSeconds >= 0 && c.startSeconds < durationSeconds)
.sort((a, b) => a.startSeconds - b.startSeconds);
const clips = [];
const seenStarts = new Set();
for (const c of valid) {
if (seenStarts.has(c.startSeconds)) continue; // no two clips may share a start time
seenStarts.add(c.startSeconds);
const end = Number.isInteger(c.endSeconds) && c.endSeconds > c.startSeconds && c.endSeconds <= durationSeconds ? c.endSeconds : null;
clips.push({
"@type": "Clip",
name: c.name,
startOffset: c.startSeconds,
endOffset: end,
url: withTimeParam(pageUrl, c.startSeconds) // same URL path as the video
});
}
return clips;
}
// Key moments, option 2: SeekToAction when the player deep links via ?t=.
function buildSeekToAction(source, pageUrl) {
if (!source.supportsTimeParam || !Number.isInteger(source.durationSeconds) || source.durationSeconds < 30) return null;
return {
"@type": "SeekToAction",
target: withTimeParam(pageUrl, "{seek_to_second_number}"),
"startOffset-input": "required name=seek_to_second_number"
};
}
// LIVE badge: BroadcastEvent with startDate and a (possibly approximate) endDate.
function buildBroadcastEvent(live) {
if (!live) return null;
const start = toIsoDateTime(live.startDate);
const end = toIsoDateTime(live.endDate);
if (!start || !end || Date.parse(end) <= Date.parse(start)) return null;
return { "@type": "BroadcastEvent", isLiveBroadcast: true, startDate: start, endDate: end };
}
function buildVideoObjectSchema(source) {
const page = source.page;
if (!passesPageGates(page)) return null;
// Required: name, thumbnailUrl, uploadDate (with timezone).
const thumbnails = (source.thumbnailUrls || []).filter(isAbsoluteUrl);
const uploadDate = toIsoDateTime(source.uploadDate);
if (!source.title || !thumbnails.length || !uploadDate) return null;
// contentUrl must be a supported video file; neither URL may be the watch page.
const contentUrl = isAbsoluteUrl(source.contentUrl) && source.contentUrl !== page.url
&& VIDEO_FILE.test(new URL(source.contentUrl).pathname) ? source.contentUrl : null;
const embedUrl = isAbsoluteUrl(source.embedUrl) && source.embedUrl !== page.url ? source.embedUrl : null;
const expires = toIsoDateTime(source.expires);
const regionsAllowed = (source.regionsAllowed || []).filter((c) => REGION_CODE.test(c));
// Use regionsAllowed or ineligibleRegion, never both.
const ineligibleRegion = regionsAllowed.length ? [] : (source.ineligibleRegion || []).filter((c) => REGION_CODE.test(c));
const views = Number.isInteger(source.viewCount) && source.viewCount >= 0 ? source.viewCount : null;
const clips = buildClips(source.chapters, source.durationSeconds, page.url);
const schema = {
"@context": "https://schema.org",
"@type": "VideoObject",
name: source.title,
description: source.description,
thumbnailUrl: thumbnails,
uploadDate,
duration: toIsoDuration(source.durationSeconds),
contentUrl,
embedUrl,
expires: expires && Date.parse(expires) > Date.parse(uploadDate) ? expires : null,
creator: buildParty(source.creator),
regionsAllowed,
ineligibleRegion,
interactionStatistic: views === null ? null : {
"@type": "InteractionCounter",
interactionType: { "@type": "WatchAction" },
userInteractionCount: views
},
hasPart: clips,
potentialAction: clips.length ? null : buildSeekToAction(source, page.url),
publication: buildBroadcastEvent(source.live)
};
return compact(schema) ?? null;
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/videos/shaping-sourdough",
// "canonicalUrl": "https://www.example.com/videos/shaping-sourdough",
// "noindex": false,
// "contentVisible": true
// },
// "title": "How to Shape a Sourdough Boule",
// "description": "A baker walks through pre-shaping, bench rest and final shaping for a round sourdough loaf.",
// "thumbnailUrls": [
// "https://www.example.com/media/shaping-sourdough-16x9.jpg",
// "https://www.example.com/media/shaping-sourdough-4x3.jpg",
// "https://www.example.com/media/shaping-sourdough-1x1.jpg"
// ],
// "uploadDate": "2026-03-14T08:00:00-05:00",
// "durationSeconds": 754,
// "contentUrl": "https://cdn.example.com/video/shaping-sourdough.mp4",
// "embedUrl": "https://www.example.com/embed/shaping-sourdough",
// "creator": {
// "type": "Organization",
// "name": "Example Bakery School",
// "url": "https://www.example.com/about"
// },
// "regionsAllowed": [
// "US",
// "CA"
// ],
// "viewCount": 18452,
// "chapters": [
// {
// "name": "Pre-shaping the dough",
// "startSeconds": 30,
// "endSeconds": 210
// },
// {
// "name": "Bench rest",
// "startSeconds": 210,
// "endSeconds": 400
// },
// {
// "name": "Final shaping and banneting",
// "startSeconds": 400,
// "endSeconds": 754
// }
// ],
// "live": null
// };
// const schema = buildVideoObjectSchema(source);
# Shared helpers
import re
from datetime import date, datetime
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parts = urlparse(value)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.hostname)
def to_iso_date(value):
if not isinstance(value, str) or not ISO_DATE.match(value):
return None
try:
date.fromisoformat(value)
except ValueError:
return None
return value
# Keeps the original timezone offset; rejects naive or unparseable values.
def to_iso_datetime(value):
match = ISO_DATETIME.match(value) if isinstance(value, str) else None
return value if match and to_iso_date(match.group(1)) else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {key: kept for key, kept in ((k, compact(v)) for k, v in value.items()) if kept is not None}
return out or None
return None if value is None or value == "" else value
# Page gates: indexable, self-canonical, marked-up content visible.
def passes_page_gates(page):
if not page or page.get("noindex") or page.get("robotsBlocked"):
return False
if not is_absolute_url(page.get("url")):
return False
if page.get("canonicalUrl") and page["canonicalUrl"] != page["url"]:
return False
return page.get("contentVisible") is True
# Video helpers
REGION_CODE = re.compile(r"^[A-Z]{2,3}$")
VIDEO_FILE = re.compile(r"\.(3gp|3g2|asf|avi|divx|m2v|m3u|m3u8|m4v|mkv|mov|mp4|mpeg|ogv|qvt|ram|rm|vob|webm|wmv|xap)$", re.I)
def is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def parse_dt(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Seconds to ISO 8601 duration, e.g. 754 -> "PT12M34S".
def to_iso_duration(seconds):
if not is_int(seconds) or seconds <= 0:
return None
h, m, s = seconds // 3600, (seconds % 3600) // 60, seconds % 60
return "PT" + (f"{h}H" if h else "") + (f"{m}M" if m else "") + (f"{s}S" if s else "")
def with_time_param(page_url, value):
return page_url + ("&" if "?" in page_url else "?") + f"t={value}"
def build_party(party):
if not party or not party.get("name"):
return None # creator/author needs name
return {
"@type": "Person" if party.get("type") == "Person" else "Organization",
"name": party["name"],
"url": party.get("url") if is_absolute_url(party.get("url")) else None,
}
# Key moments, option 1: Clip per chapter (video must be 30s or longer).
def build_clips(chapters, duration_seconds, page_url):
if not is_int(duration_seconds) or duration_seconds < 30:
return []
valid = sorted(
(c for c in (chapters or [])
if c and c.get("name") and is_int(c.get("startSeconds"))
and 0 <= c["startSeconds"] < duration_seconds),
key=lambda c: c["startSeconds"],
)
clips, seen_starts = [], set()
for c in valid:
if c["startSeconds"] in seen_starts:
continue # no two clips may share a start time
seen_starts.add(c["startSeconds"])
end = c.get("endSeconds")
end_ok = is_int(end) and c["startSeconds"] < end <= duration_seconds
clips.append({
"@type": "Clip",
"name": c["name"],
"startOffset": c["startSeconds"],
"endOffset": end if end_ok else None,
"url": with_time_param(page_url, c["startSeconds"]), # same URL path as the video
})
return clips
# Key moments, option 2: SeekToAction when the player deep links via ?t=.
def build_seek_to_action(source, page_url):
duration = source.get("durationSeconds")
if not source.get("supportsTimeParam") or not is_int(duration) or duration < 30:
return None
return {
"@type": "SeekToAction",
"target": with_time_param(page_url, "{seek_to_second_number}"),
"startOffset-input": "required name=seek_to_second_number",
}
# LIVE badge: BroadcastEvent with startDate and a (possibly approximate) endDate.
def build_broadcast_event(live):
if not live:
return None
start = to_iso_datetime(live.get("startDate"))
end = to_iso_datetime(live.get("endDate"))
if not start or not end or parse_dt(end) <= parse_dt(start):
return None
return {"@type": "BroadcastEvent", "isLiveBroadcast": True, "startDate": start, "endDate": end}
def build_video_object_schema(source):
page = source.get("page")
if not passes_page_gates(page):
return None
# Required: name, thumbnailUrl, uploadDate (with timezone).
thumbnails = [u for u in (source.get("thumbnailUrls") or []) if is_absolute_url(u)]
upload_date = to_iso_datetime(source.get("uploadDate"))
if not source.get("title") or not thumbnails or not upload_date:
return None
# contentUrl must be a supported video file; neither URL may be the watch page.
content_url = source.get("contentUrl")
if not (is_absolute_url(content_url) and content_url != page["url"]
and VIDEO_FILE.search(urlparse(content_url).path)):
content_url = None
embed_url = source.get("embedUrl")
if not (is_absolute_url(embed_url) and embed_url != page["url"]):
embed_url = None
expires = to_iso_datetime(source.get("expires"))
regions_allowed = [c for c in (source.get("regionsAllowed") or []) if REGION_CODE.match(c)]
# Use regionsAllowed or ineligibleRegion, never both.
ineligible_region = [] if regions_allowed else [
c for c in (source.get("ineligibleRegion") or []) if REGION_CODE.match(c)]
views = source.get("viewCount")
views = views if is_int(views) and views >= 0 else None
clips = build_clips(source.get("chapters"), source.get("durationSeconds"), page["url"])
schema = {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": source["title"],
"description": source.get("description"),
"thumbnailUrl": thumbnails,
"uploadDate": upload_date,
"duration": to_iso_duration(source.get("durationSeconds")),
"contentUrl": content_url,
"embedUrl": embed_url,
"expires": expires if expires and parse_dt(expires) > parse_dt(upload_date) else None,
"creator": build_party(source.get("creator")),
"regionsAllowed": regions_allowed,
"ineligibleRegion": ineligible_region,
"interactionStatistic": None if views is None else {
"@type": "InteractionCounter",
"interactionType": {"@type": "WatchAction"},
"userInteractionCount": views,
},
"hasPart": clips,
"potentialAction": None if clips else build_seek_to_action(source, page["url"]),
"publication": build_broadcast_event(source.get("live")),
}
return compact(schema)
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "How to Shape a Sourdough Boule",
"description": "A baker walks through pre-shaping, bench rest and final shaping for a round sourdough loaf.",
"thumbnailUrl": [
"https://www.example.com/media/shaping-sourdough-16x9.jpg",
"https://www.example.com/media/shaping-sourdough-4x3.jpg",
"https://www.example.com/media/shaping-sourdough-1x1.jpg"
],
"uploadDate": "2026-03-14T08:00:00-05:00",
"duration": "PT12M34S",
"contentUrl": "https://cdn.example.com/video/shaping-sourdough.mp4",
"embedUrl": "https://www.example.com/embed/shaping-sourdough",
"creator": {
"@type": "Organization",
"name": "Example Bakery School",
"url": "https://www.example.com/about"
},
"regionsAllowed": [
"US",
"CA"
],
"interactionStatistic": {
"@type": "InteractionCounter",
"interactionType": {
"@type": "WatchAction"
},
"userInteractionCount": 18452
},
"hasPart": [
{
"@type": "Clip",
"name": "Pre-shaping the dough",
"startOffset": 30,
"endOffset": 210,
"url": "https://www.example.com/videos/shaping-sourdough?t=30"
},
{
"@type": "Clip",
"name": "Bench rest",
"startOffset": 210,
"endOffset": 400,
"url": "https://www.example.com/videos/shaping-sourdough?t=210"
},
{
"@type": "Clip",
"name": "Final shaping and banneting",
"startOffset": 400,
"endOffset": 754,
"url": "https://www.example.com/videos/shaping-sourdough?t=400"
}
]
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's documentation requires and recommends for each Video feature, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Video Google doc
Required, one of: In creator: if creator is specified, provide name or alternateName.
Required, one of: In author: if author is specified, provide name or alternateName.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The title of the video. |
| thumbnailUrl | Required | URL, can repeat | A URL to the video's unique thumbnail image. Images at least 60px wide. |
| uploadDate | Required | DateTime | ISO 8601 date and time, with timezone, when the video was first published. |
| contentUrl | Recommended | URL | The URL of the video file itself (not the watch page) |
| creator | Recommended | Person or Organization, can repeat | The person or organization that created the video. Or use author. |
| creator.name | One of | Text | The name of the creator or organization. |
| creator.url | Recommended | URL | A page that uniquely identifies the creator. |
| author | Recommended | Person or Organization, can repeat | Accepted alternative to creator: the person or organization that created or published the video. If specified, provide either name or alternateName. Or use creator. |
| author.name | One of | Text | The name of the creator or organization. |
| author.url | Recommended | URL | A link to a web page that uniquely identifies the creator or organization (such as a profile page or homepage). |
| description | Recommended | Text | A unique description of the video. |
| duration | Recommended | Duration | In ISO 8601 format (e.g. PT1M33S) |
| embedUrl | Recommended | URL | The URL of the video player (not the watch page) |
| expires | Recommended | DateTime | Only if the video expires (ISO 8601 with timezone) |
| hasPart | Recommended | Clip, can repeat | Clip items for important video segments (key moments) |
| ineligibleRegion | Recommended | Place or Text, can repeat | IneligibleRegion: use ISO 3166-1 alpha-2 or alpha-3 country codes. |
| interactionStatistic | Recommended | InteractionCounter, can repeat | E.g. WatchAction count. |
| interactionStatistic.interactionType | Recommended | Enum | InteractionType must be WatchAction, LikeAction, CommentAction or ShareAction. One of: WatchAction, LikeAction, CommentAction, ShareAction. |
| interactionStatistic.userInteractionCount | Recommended | Integer | As an integer. |
| publication | Recommended | BroadcastEvent, can repeat | BroadcastEvent for livestreams (LIVE badge) |
| regionsAllowed | Recommended | Place or Text, can repeat | RegionsAllowed: use ISO 3166-1 alpha-2 or alpha-3 country codes. |
Video key moments Google doc
| Property | Expects | Notes | |
|---|---|---|---|
| hasPart | Recommended | Clip, can repeat | Clip items to define key moments. |
| hasPart.name | Required | Text | Clip requires name: a descriptive title for the clip. |
| hasPart.startOffset | Required | Number | Clip requires startOffset in seconds from the start of the video. |
| hasPart.url | Required | URL | Clip requires url pointing to the clip start time on the same URL path as the video. |
| hasPart.endOffset | Recommended | Number | EndOffset in seconds. |
| potentialAction | Recommended | SeekToAction | SeekToAction to enable automatic key moments. |
| potentialAction.startOffset-input | Required | Text | SeekToAction requires startOffset-input set to 'required name=seek_to_second_number'. One of: required name=seek_to_second_number. |
| potentialAction.target | Required | EntryPoint or URL | SeekToAction requires target URL containing {seek_to_second_number}. |
Video LIVE badge Google doc
| Property | Expects | Notes | |
|---|---|---|---|
| publication | Required | BroadcastEvent, can repeat | LIVE badge requires publication with a BroadcastEvent. |
| publication.isLiveBroadcast | Required | Boolean | Publication.isLiveBroadcast must be true. One of: true. |
| publication.startDate | Required | DateTime | Publication.startDate is required (ISO 8601 DateTime) |
| publication.endDate | Required | DateTime | Publication.endDate is required once the stream has ended; provide an approximate one in advance. |