tool-ui
blockvideosource: ok · hit
Video
Video playback with controls and poster.
npx shadcn add "https://www.tool-ui.com/r/video.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
formszod
Registry dependencies
button
Files16 · source cached 10/14/2026, 11:22:07 AM
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
components/tool-ui/shared/media/aspect-ratio.ts
import { z } from "zod";
export const AspectRatioSchema = z
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
.default("auto");
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
export type MediaFit = z.infer<typeof MediaFitSchema>;
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
auto: "",
"1:1": "aspect-square",
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"9:16": "aspect-[9/16]",
};
export function getRatioClass(ratio: AspectRatio): string {
return RATIO_CLASS_MAP[ratio];
}
export function getFitClass(fit: MediaFit): string {
return fit === "cover" ? "object-cover" : "object-contain";
}
components/tool-ui/shared/media/format-utils.ts
/**
* Format duration in milliseconds to human-readable string.
* @example formatDuration(128000) => "2:08"
* @example formatDuration(3661000) => "1:01:01"
*/
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.round(durationMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Format file size in bytes to human-readable string.
* @example formatFileSize(1024) => "1 KB"
* @example formatFileSize(1536000) => "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let size = bytes / 1024;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
components/tool-ui/shared/media/index.ts
export {
AspectRatioSchema,
MediaFitSchema,
RATIO_CLASS_MAP,
getRatioClass,
getFitClass,
type AspectRatio,
type MediaFit,
} from "./aspect-ratio";
export { OVERLAY_GRADIENT } from "./overlay-gradient";
export { formatDuration, formatFileSize } from "./format-utils";
export { sanitizeHref } from "./sanitize-href";
export {
resolveSafeNavigationHref,
openSafeNavigationHref,
} from "./safe-navigation";
components/tool-ui/shared/media/overlay-gradient.ts
/**
* Eased gradient for hover overlays on media elements.
* Creates a smooth fade from opaque black at top to transparent.
*
* @see https://larsenwork.com/easing-gradients/
*/
export const OVERLAY_GRADIENT = `linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 8.3%,
hsla(0, 0%, 0%, 0.951) 16.6%,
hsla(0, 0%, 0%, 0.896) 24.6%,
hsla(0, 0%, 0%, 0.825) 32.5%,
hsla(0, 0%, 0%, 0.741) 40.1%,
hsla(0, 0%, 0%, 0.648) 47.6%,
hsla(0, 0%, 0%, 0.55) 54.8%,
hsla(0, 0%, 0%, 0.45) 61.7%,
hsla(0, 0%, 0%, 0.352) 68.3%,
hsla(0, 0%, 0%, 0.259) 74.5%,
hsla(0, 0%, 0%, 0.175) 80.4%,
hsla(0, 0%, 0%, 0.104) 86%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.8%,
hsla(0, 0%, 0%, 0) 100%
)` as const;
components/tool-ui/shared/media/safe-navigation.ts
import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
export function openSafeNavigationHref(href: string | undefined): boolean {
if (!href || typeof window === "undefined") {
return false;
}
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
components/tool-ui/shared/media/sanitize-href.ts
/**
* Sanitize a URL to ensure it's safe for use in href attributes.
* Allows:
* - Absolute http(s) URLs
* - Relative URLs (/path, ./path, ../path, ?query, #hash)
*
* @returns The sanitized URL string, or undefined if invalid/unsafe
*/
export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
components/tool-ui/shared/parse.ts
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
components/tool-ui/video/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*/
"use client";
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
components/tool-ui/video/context.tsx
"use client";
import * as React from "react";
export interface VideoPlaybackState {
playing: boolean;
muted: boolean;
}
export interface VideoContextValue {
state: VideoPlaybackState;
setState: (patch: Partial<VideoPlaybackState>) => void;
videoElement: HTMLVideoElement | null;
setVideoElement: (node: HTMLVideoElement | null) => void;
}
const VideoContext = React.createContext<VideoContextValue | null>(null);
export function useVideo() {
const ctx = React.use(VideoContext);
if (!ctx) {
throw new Error("useVideo must be used within a <VideoProvider />");
}
return ctx;
}
export interface VideoProviderProps {
children: React.ReactNode;
defaultState?: Partial<VideoPlaybackState>;
}
export function VideoProvider({ children, defaultState }: VideoProviderProps) {
const [state, setStateInternal] = React.useState<VideoPlaybackState>({
playing: defaultState?.playing ?? false,
muted: defaultState?.muted ?? true,
});
const [videoElement, setVideoElement] =
React.useState<HTMLVideoElement | null>(null);
const setState = React.useCallback((patch: Partial<VideoPlaybackState>) => {
setStateInternal((prev) => ({ ...prev, ...patch }));
}, []);
const value = React.useMemo(
() => ({ state, setState, videoElement, setVideoElement }),
[state, setState, videoElement],
);
return (
<VideoContext.Provider value={value}>{children}</VideoContext.Provider>
);
}
components/tool-ui/video/index.ts
export { Video } from "./video";
export type { VideoProps } from "./video";
export type { SerializableVideo, Source } from "./schema";
components/tool-ui/video/README.md
# Video
Implementation for the "video" Tool UI surface.
## Files
- public exports: components/tool-ui/video/index.ts
- serializable schema + parse helpers: components/tool-ui/video/schema.ts
## Companion assets
- Docs page: app/docs/video/content.mdx
- Preset payload: lib/presets/video.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/video/schema.ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { AspectRatioSchema, MediaFitSchema } from "../shared/media";
export const SourceSchema = z.object({
label: z.string(),
iconUrl: z.url().optional(),
url: z.url().optional(),
});
export type Source = z.infer<typeof SourceSchema>;
export const SerializableVideoSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
assetId: z.string(),
src: z.url(),
poster: z.url().optional(),
title: z.string().optional(),
description: z.string().optional(),
href: z.url().optional(),
domain: z.string().optional(),
durationMs: z.number().int().positive().optional(),
ratio: AspectRatioSchema.optional(),
fit: MediaFitSchema.optional(),
createdAt: z.string().datetime().optional(),
locale: z.string().optional(),
source: SourceSchema.optional(),
});
export type SerializableVideo = z.infer<typeof SerializableVideoSchema>;
const SerializableVideoSchemaContract = defineToolUiContract(
"Video",
SerializableVideoSchema,
);
export const parseSerializableVideo: (input: unknown) => SerializableVideo =
SerializableVideoSchemaContract.parse;
export const safeParseSerializableVideo: (
input: unknown,
) => SerializableVideo | null = SerializableVideoSchemaContract.safeParse;
components/tool-ui/video/video-helpers.ts
import type { SerializableVideo } from "./schema";
import { resolveSafeNavigationHref, sanitizeHref } from "../shared/media";
export type VideoMediaEvent = "mute" | "unmute";
export interface ResolvedVideoNavigation {
sanitizedHref: string | undefined;
sanitizedSourceUrl: string | undefined;
primaryHref: string | undefined;
}
export function getMuteMediaEvent(
previousMuted: boolean,
nextMuted: boolean,
): VideoMediaEvent | null {
if (previousMuted === nextMuted) {
return null;
}
return nextMuted ? "mute" : "unmute";
}
export function resolveVideoNavigation(
rawHref: string | undefined,
rawSourceUrl: string | undefined,
): ResolvedVideoNavigation {
const sanitizedHref = sanitizeHref(rawHref);
const sanitizedSourceUrl = sanitizeHref(rawSourceUrl);
return {
sanitizedHref,
sanitizedSourceUrl,
primaryHref: resolveSafeNavigationHref(sanitizedHref, sanitizedSourceUrl),
};
}
export function normalizeVideoDataForCallback(
video: SerializableVideo,
normalized: {
ratio: NonNullable<SerializableVideo["ratio"]>;
fit: NonNullable<SerializableVideo["fit"]>;
locale: string;
sanitizedHref: string | undefined;
sanitizedSourceUrl: string | undefined;
},
): SerializableVideo {
return {
...video,
ratio: normalized.ratio,
fit: normalized.fit,
href: normalized.sanitizedHref,
source: video.source
? { ...video.source, url: normalized.sanitizedSourceUrl }
: undefined,
locale: normalized.locale,
};
}
components/tool-ui/video/video.tsx
"use client";
import * as React from "react";
import { ExternalLink, Play } from "lucide-react";
import { cn, Button } from "./_adapter";
import {
formatDuration,
getFitClass,
openSafeNavigationHref,
OVERLAY_GRADIENT,
RATIO_CLASS_MAP,
} from "../shared/media";
import { VideoProvider, useVideo } from "./context";
import type { SerializableVideo } from "./schema";
import {
getMuteMediaEvent,
normalizeVideoDataForCallback,
resolveVideoNavigation,
} from "./video-helpers";
const FALLBACK_LOCALE = "en-US";
export interface VideoProps extends SerializableVideo {
className?: string;
// Keep behavior flags intentionally minimal; prefer explicit variants over more booleans.
autoPlay?: boolean;
defaultMuted?: boolean;
onNavigate?: (href: string, video: SerializableVideo) => void;
onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void;
}
function VideoRoot(props: VideoProps) {
const { defaultMuted = true, ...rest } = props;
return (
<VideoProvider defaultState={{ muted: defaultMuted }}>
<VideoInner {...rest} />
</VideoProvider>
);
}
function VideoInner(props: Omit<VideoProps, "defaultMuted">) {
const {
className,
autoPlay = true,
onNavigate,
onMediaEvent,
...serializable
} = props;
const {
id,
src,
poster,
title,
description,
href: rawHref,
domain,
durationMs,
ratio = "16:9",
fit = "cover",
createdAt,
source,
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const { sanitizedHref, sanitizedSourceUrl, primaryHref } =
resolveVideoNavigation(rawHref, source?.url);
const videoData: SerializableVideo = normalizeVideoDataForCallback(
serializable,
{
ratio,
fit,
locale,
sanitizedHref,
sanitizedSourceUrl,
},
);
const { state, setState, setVideoElement } = useVideo();
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const previousMutedRef = React.useRef(state.muted);
React.useEffect(() => {
setVideoElement(videoRef.current);
return () => setVideoElement(null);
}, [setVideoElement]);
React.useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (video.muted !== state.muted) {
video.muted = state.muted;
}
}, [state.muted]);
React.useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (state.playing && video.paused) {
void video.play().catch(() => undefined);
} else if (!state.playing && !video.paused) {
video.pause();
}
}, [state.playing]);
const navigate = (targetHref: string) => {
if (onNavigate) {
onNavigate(targetHref, videoData);
} else {
openSafeNavigationHref(targetHref);
}
};
const handleWatch = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
};
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!primaryHref) return;
navigate(primaryHref);
};
const sourceLabel = source?.label;
const metadataDomain = domain && domain !== sourceLabel ? domain : undefined;
const hasMetadata = Boolean(
description || sourceLabel || metadataDomain || durationMs || createdAt,
);
const hasOverlay = Boolean(title || primaryHref);
return (
<article
className={cn("relative w-full min-w-80 max-w-md", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="video"
>
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border border-border bg-card text-sm shadow-xs",
)}
>
<div
className={cn(
"group relative w-full overflow-hidden bg-black",
ratio !== "auto" ? RATIO_CLASS_MAP[ratio] : "aspect-video",
)}
>
<video
ref={videoRef}
className={cn(
"relative z-10 h-full w-full transition-transform duration-200 group-hover:scale-[1.01]",
getFitClass(fit),
ratio !== "auto" && "absolute inset-0 h-full w-full",
)}
src={src}
poster={poster}
controls
playsInline
autoPlay={autoPlay}
preload="metadata"
muted={state.muted}
onPlay={() => {
setState({ playing: true });
onMediaEvent?.("play");
}}
onPause={() => {
setState({ playing: false });
onMediaEvent?.("pause");
}}
onVolumeChange={(event) => {
const target = event.currentTarget;
setState({ muted: target.muted });
const mediaEvent = getMuteMediaEvent(
previousMutedRef.current,
target.muted,
);
previousMutedRef.current = target.muted;
if (mediaEvent) {
onMediaEvent?.(mediaEvent);
}
}}
/>
{hasOverlay && (
<>
<div
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-32 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
style={{ backgroundImage: OVERLAY_GRADIENT }}
/>
<div className="absolute inset-x-0 top-0 z-30 flex items-start justify-between gap-2 px-5 pt-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
{title ? (
<div className="line-clamp-2 max-w-[70%] font-semibold text-white drop-shadow-sm">
{title}
</div>
) : (
<span className="sr-only">Video controls</span>
)}
<div className="flex items-center gap-2">
{primaryHref && (
<Button
variant="secondary"
size="sm"
onClick={handleOpen}
className="bg-black/55 text-white hover:bg-black/70"
>
<ExternalLink
className="mr-1 h-4 w-4"
aria-hidden="true"
/>
Open
</Button>
)}
<Button
variant="default"
size="sm"
onClick={handleWatch}
className="shadow-sm"
>
<Play className="mr-1 h-4 w-4" aria-hidden="true" />
Watch
</Button>
</div>
</div>
</>
)}
</div>
{hasMetadata && (
<div className="flex flex-col gap-1.5 px-4 py-3">
{description && (
<p className="text-foreground line-clamp-2 text-sm leading-snug">
{description}
</p>
)}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{metadataDomain && <span>{metadataDomain}</span>}
{typeof durationMs === "number" && (
<span>{formatDuration(durationMs)}</span>
)}
{createdAt && (
<time dateTime={createdAt}>
{formatCreatedAt(createdAt, locale)}
</time>
)}
</div>
</div>
)}
</div>
</article>
);
}
function formatCreatedAt(createdAt: string, locale: string): string {
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) {
return createdAt;
}
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
}
type VideoComponent = typeof VideoRoot & {
Root: typeof VideoRoot;
};
export const Video = Object.assign(VideoRoot, {
Root: VideoRoot,
}) as VideoComponent;