Merge pull request #941 from heygen-com/worktree-feat+media-design-panel

feat(studio): media properties panel for video/audio elements
This commit is contained in:
Miguel Ángel
2026-05-18 23:28:08 +02:00
committed by GitHub
12 changed files with 407 additions and 107 deletions
@@ -1,11 +1,6 @@
import { memo } from "react";
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import {
collectDomEditLayerItems,
getDomEditLayerKey,
type DomEditSelection,
type DomEditLayerItem,
} from "./domEditing";
import { type DomEditSelection } from "./domEditing";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import type { ImportedFontAsset } from "./fontAssets";
import {
@@ -16,6 +11,7 @@ import {
RESPONSIVE_GRID,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
// Re-export helpers that external consumers import from this module
@@ -33,6 +29,7 @@ export {
interface PropertyPanelProps {
projectId: string;
projectDir: string | null;
assets: string[];
element: DomEditSelection | null;
multiSelectCount?: number;
@@ -40,6 +37,7 @@ interface PropertyPanelProps {
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
@@ -51,68 +49,6 @@ interface PropertyPanelProps {
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
activeCompositionPath?: string | null;
onSelectLayer?: (layer: DomEditLayerItem) => void;
}
/* ------------------------------------------------------------------ */
/* LayerTree */
/* ------------------------------------------------------------------ */
function LayerTree({
element,
activeCompositionPath,
onSelectLayer,
}: {
element: DomEditSelection | null;
activeCompositionPath: string | null;
onSelectLayer: (layer: DomEditLayerItem) => void;
}) {
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
const layers = collectDomEditLayerItems(element?.element, {
activeCompositionPath,
isMasterView,
});
if (layers.length <= 1) return null;
const selectedKey = element ? getDomEditLayerKey(element) : null;
return (
<Section title="Layers" icon={<Layers size={15} />}>
<div className="space-y-0.5">
{layers.map((layer) => {
const selected = layer.key === selectedKey;
return (
<button
key={layer.key}
type="button"
onClick={() => onSelectLayer(layer)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors ${
selected
? "bg-studio-accent/14 text-studio-accent"
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
}`}
style={{ paddingLeft: 8 + layer.depth * 12 }}
>
<span
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold uppercase ${
selected
? "bg-studio-accent/18 text-studio-accent"
: "bg-neutral-800 text-neutral-500"
}`}
>
{layer.tagName.slice(0, 2)}
</span>
<span className="min-w-0 flex-1 truncate text-xs">{layer.label}</span>
{layer.childCount > 0 && (
<span className="text-[9px] tabular-nums text-neutral-500">{layer.childCount}</span>
)}
</button>
);
})}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
@@ -182,6 +118,7 @@ function TimingSection({
export const PropertyPanel = memo(function PropertyPanel({
projectId,
projectDir,
assets,
element,
multiSelectCount = 0,
@@ -189,6 +126,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onClearSelection,
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
@@ -200,8 +138,6 @@ export const PropertyPanel = memo(function PropertyPanel({
onImportAssets,
fontAssets = [],
onImportFonts,
activeCompositionPath = null,
onSelectLayer,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
@@ -331,11 +267,18 @@ export const PropertyPanel = memo(function PropertyPanel({
onRemoveTextField={onRemoveTextField}
/>
{onSelectLayer && (
<LayerTree
{element.dataAttributes.start != null && (
<TimingSection element={element} onSetAttribute={onSetAttribute} />
)}
{isMediaElement(element) && (
<MediaSection
projectDir={projectDir}
element={element}
activeCompositionPath={activeCompositionPath}
onSelectLayer={onSelectLayer}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>
)}
@@ -385,10 +328,6 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
</Section>
{element.dataAttributes.start != null && (
<TimingSection element={element} onSetAttribute={onSetAttribute} />
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
@@ -42,6 +42,8 @@ export const CURATED_STYLE_PROPERTIES = [
"backdrop-filter",
"z-index",
"transform",
"object-fit",
"object-position",
] as const;
export interface DomEditCapabilities {
@@ -0,0 +1,234 @@
import { useState } from "react";
import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
formatNumericValue,
LABEL,
parseNumericValue,
RESPONSIVE_GRID,
} from "./propertyPanelHelpers";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
const MEDIA_TAGS = new Set(["video", "audio"]);
export function isMediaElement(element: DomEditSelection): boolean {
return MEDIA_TAGS.has(element.tagName);
}
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
export function MediaSection({
projectDir,
element,
styles,
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
}: {
projectDir: string | null;
element: DomEditSelection;
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
}) {
const isVideo = element.tagName === "video";
const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const hasLoop = el.hasAttribute("loop");
const hasMuted = el.hasAttribute("muted");
const hasAudio = element.dataAttributes["has-audio"] === "true";
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const objectFit = styles["object-fit"] || "contain";
const objectPosition = styles["object-position"] || "center";
const sourceDuration =
Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
(el as HTMLMediaElement).duration ||
0;
const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
const srcAttr = el.getAttribute("src") ?? "";
const [copied, setCopied] = useState(false);
const absoluteSrc =
projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
return (
<Section
title={isVideo ? "Video" : "Audio"}
icon={isVideo ? <Film size={15} /> : <Music size={15} />}
>
<div className="space-y-4">
{srcAttr && (
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(absoluteSrc).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="flex h-6 items-center gap-1 rounded-lg border border-neutral-700 bg-neutral-950 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200"
>
{copied ? <Check size={11} /> : <ClipboardList size={11} />}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
<div
className="mt-1 truncate text-[11px] font-medium text-neutral-300"
title={absoluteSrc}
>
{absoluteSrc}
</div>
</div>
)}
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Volume</span>
<SliderControl
value={volumePercent}
min={0}
max={100}
step={1}
displayValue={`${volumePercent}%`}
formatDisplayValue={(next) => `${Math.round(next)}%`}
onCommit={(next) => {
void onSetAttribute("volume", formatNumericValue(next / 100));
}}
/>
</div>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Playback rate</span>
<SliderControl
value={playbackRate * 100}
min={25}
max={300}
step={5}
displayValue={`${formatNumericValue(playbackRate)}x`}
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
onCommit={(next) => {
void onSetAttribute("playback-rate", formatNumericValue(next / 100));
}}
/>
</div>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Media start</span>
<SliderControl
value={Math.round(mediaStart * 100)}
min={0}
max={mediaStartMax * 100}
step={10}
displayValue={formatTimingValue(mediaStart)}
formatDisplayValue={(next) => formatTimingValue(next / 100)}
onCommit={(next) => {
void onSetAttribute("media-start", (next / 100).toFixed(2));
}}
/>
</div>
<div className={RESPONSIVE_GRID}>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Loop</span>
<SegmentedControl
value={hasLoop ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
}}
options={[
{ label: "On", value: "on" },
{ label: "Off", value: "off" },
]}
/>
</div>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Muted</span>
<SegmentedControl
value={hasMuted ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
}}
options={[
{ label: "On", value: "on" },
{ label: "Off", value: "off" },
]}
/>
</div>
</div>
{isVideo && (
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Has audio track</span>
<SegmentedControl
value={hasAudio ? "yes" : "no"}
onChange={(next) => {
if (next === "yes") {
void onSetAttribute("has-audio", "true");
void onSetHtmlAttribute("muted", null);
} else {
void onSetAttribute("has-audio", "");
void onSetHtmlAttribute("muted", "true");
}
}}
options={[
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
]}
/>
</div>
)}
{isVideo && (
<>
<div className={RESPONSIVE_GRID}>
<SelectField
label="Fit"
value={objectFit}
onChange={(next) => {
void onSetStyle("object-fit", next);
}}
options={["contain", "cover", "fill", "none", "scale-down"]}
/>
<SelectField
label="Position"
value={objectPosition}
onChange={(next) => {
void onSetStyle("object-position", next);
}}
options={[
"center",
"top",
"bottom",
"left",
"right",
"left top",
"right top",
"left bottom",
"right bottom",
]}
/>
</div>
</>
)}
</div>
</Section>
);
}