Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stack-narrow-video-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ioai/lerobot-studio': patch
---

Fix two embedding problems. Narrow containers (under 600px wide, measured on the viewer container rather than the window) now default to a single stacked column of panels that scrolls, and the chart panel toolbar wraps instead of clipping its line-chart and split-view controls; the desktop layout is unchanged. Every `<video>` (and the frame-extraction video and image decoder) now sets `crossOrigin="anonymous"`, so the media request and the later CORS `fetch()` of the same presigned URL share one CORS cache entry instead of the `fetch()` being blocked by a cached no-cors response; no credentials are sent.
11 changes: 11 additions & 0 deletions src/core/utils/featureUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,17 @@ function getLegacyLayoutVisualRows(
/** Narrow main panes cannot fit 4 Dockview tiles (100px min each) without clipping. */
export const NARROW_VISUAL_ROW_WIDTH = 900;

/** Container width (px) below which panels default to a single stacked column. */
export const STACKED_PANELS_MAX_WIDTH = 600;

export function isStackedPanelsWidth(containerWidth: number): boolean {
return (
Number.isFinite(containerWidth) &&
containerWidth > 0 &&
containerWidth < STACKED_PANELS_MAX_WIDTH
);
}

export function getVisualMaxPerRow(containerWidth: number): number {
return containerWidth > 0 && containerWidth < NARROW_VISUAL_ROW_WIDTH ? 2 : 4;
}
Expand Down
1 change: 1 addition & 0 deletions src/platform/services/mediaCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export async function getVideoFramePngBlob(
}

const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
Expand Down
75 changes: 61 additions & 14 deletions src/react/components/DockviewLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { LeRobotFeature } from '@/core';
import 'dockview-react/dist/styles/dockview.css';
import { VideoPanel, ImagePanel, ChartPanel, RawPanel, PanelErrorBoundary } from './panels';
import { PanelTitle } from './PanelTitle';
import { getAutoLayoutVisualRows, getVisualMaxPerRow } from '@/core';
import { getAutoLayoutVisualRows, getVisualMaxPerRow, isStackedPanelsWidth } from '@/core';
import { safeAddPanel } from '../utils/dockviewPanelId';

type DockviewSerializedGrid = {
Expand All @@ -21,6 +21,9 @@ type GridNode =
| { type: 'leaf'; data: { id: string; activePanel: string; panels: string[] }; size: number }
| { type: 'branch'; data: GridNode[]; size: number };

/** Minimum height of each panel in the stacked (narrow) column; the column scrolls. */
const STACKED_PANEL_MIN_HEIGHT_PX = 220;

// Per-instance API context (multi-embed safe; no module singleton)
const DockviewApiContext = createContext<DockviewApi | null>(null);

Expand Down Expand Up @@ -76,12 +79,16 @@ export const DockviewLayout: React.FC = () => {
const { info } = useLeRobotData();
const dockviewRef = useRef<HTMLDivElement>(null);
const [maxPerRow, setMaxPerRow] = useState(4);
// Narrow containers (embedded viewers on phones) get a single stacked column.
const [stacked, setStacked] = useState(false);
const [panelCount, setPanelCount] = useState(0);

useEffect(() => {
const el = dockviewRef.current;
if (!el) return;
const update = (width: number) => {
setMaxPerRow(getVisualMaxPerRow(width));
setStacked(isStackedPanelsWidth(width));
};
update(el.clientWidth);
const observer = new ResizeObserver((entries) => {
Expand Down Expand Up @@ -279,6 +286,8 @@ export const DockviewLayout: React.FC = () => {
.filter((v): v is NonNullable<typeof v> => Boolean(v));
const visibleVisual = [...row1Visual, ...row2Visual];

setPanelCount(0);

// 如果没有视觉源,降级为 chart + raw 横向均分(避免空白)
if (visibleVisual.length === 0) {
safeAddPanel(api, {
Expand All @@ -294,11 +303,35 @@ export const DockviewLayout: React.FC = () => {
title: t('panels.raw.title'),
params: {},
tabComponent: 'default',
position: { referencePanel: 'chart-panel', direction: 'right' },
position: { referencePanel: 'chart-panel', direction: stacked ? 'below' : 'right' },
});
return;
}

if (stacked) {
let previousId: string | null = null;
const stackedPanels = [
...visibleVisual.map((v) => ({
id: v.id,
component: v.component,
title: v.title,
params: v.params as { featureKey?: string },
})),
{ id: 'chart-panel', component: 'chart' as const, title: t('common.chart'), params: {} },
{ id: 'raw-panel', component: 'raw' as const, title: t('panels.raw.title'), params: {} },
];
for (const p of stackedPanels) {
safeAddPanel(api, {
...p,
tabComponent: 'default',
...(previousId ? { position: { referencePanel: previousId, direction: 'below' } } : {}),
});
previousId = p.id;
}
setPanelCount(stackedPanels.length);
return;
}

const firstVisualId = row1Visual[0].id;

// 先创建首个视觉面板
Expand Down Expand Up @@ -377,7 +410,7 @@ export const DockviewLayout: React.FC = () => {
row1Visual.map((v) => v.id),
row2Visual.map((v) => v.id),
);
}, [info, t, maxPerRow]);
}, [info, t, maxPerRow, stacked]);

const onReady = (event: DockviewReadyEvent) => {
const { api } = event;
Expand All @@ -395,17 +428,31 @@ export const DockviewLayout: React.FC = () => {

return (
<DockviewApiContext.Provider value={dockviewApi}>
<div ref={dockviewRef} className="w-full h-full">
<DockviewReact
components={components}
tabComponents={tabComponents}
defaultTabComponent={PanelTitle}
singleTabMode="fullwidth"
/** 关闭标签栏溢出下拉(长标题时否则会遮挡 PanelTitle 右侧操作区) */
disableTabsOverflowList
onReady={onReady}
className={resolvedTheme === 'dark' ? 'dockview-theme-dark' : 'dockview-theme-light'}
/>
<div
ref={dockviewRef}
className="w-full h-full"
data-layout={stacked ? 'stacked' : 'split'}
style={stacked ? { overflowY: 'auto', overflowX: 'hidden' } : undefined}
>
<div
className="w-full"
style={{
height: stacked
? `max(100%, ${Math.max(panelCount, 1) * STACKED_PANEL_MIN_HEIGHT_PX}px)`
: '100%',
}}
>
<DockviewReact
components={components}
tabComponents={tabComponents}
defaultTabComponent={PanelTitle}
singleTabMode="fullwidth"
/** 关闭标签栏溢出下拉(长标题时否则会遮挡 PanelTitle 右侧操作区) */
disableTabsOverflowList
onReady={onReady}
className={resolvedTheme === 'dark' ? 'dockview-theme-dark' : 'dockview-theme-light'}
/>
</div>
</div>
</DockviewApiContext.Provider>
);
Expand Down
2 changes: 1 addition & 1 deletion src/react/components/panels/ChartPanel/ChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,7 @@ const ChartPanelContent: React.FC<ChartPanelProps> = ({ params }) => {
className="w-full h-full flex flex-col bg-background text-foreground overflow-hidden"
>
<div className="flex flex-wrap items-center justify-between gap-y-1 px-3 py-1.5 border-b bg-muted/20">
<div className="flex items-center gap-2 overflow-hidden mr-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 mr-2">
<ChartJointFilterDropdown {...jointFilterDropdownProps} />
<Button
type="button"
Expand Down
1 change: 1 addition & 0 deletions src/react/components/panels/ImagePanel/ImagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export const ImagePanel: React.FC<ImagePanelProps> = ({ params }) => {
if (isStillCurrent) {
// 将 URL 转成 ImageBitmap 并绘制
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Image decode failed'));
Expand Down
1 change: 1 addition & 0 deletions src/react/components/panels/VideoPanel/VideoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ const VideoPanelContent: React.FC<VideoPanelProps> = ({ params }) => {
transform: 'translateZ(0)',
backfaceVisibility: 'hidden' as const,
}}
crossOrigin="anonymous"
muted
playsInline
preload="auto"
Expand Down
41 changes: 41 additions & 0 deletions tests/browser/embed-layout.browser.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,47 @@ describe('browser: embedded layout follows the host container', () => {
expect(separator().getAttribute('aria-valuenow')).toBe('400');
}, 60_000);

it('stacks dockview panels in one column at 390px and keeps the desktop grid at 1100px', async () => {
const layout = await mount(390);
await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
const dock = () =>
host.querySelector<HTMLElement>(
'[data-testid="lerobot-viewer-layout"] [data-layout]:not([data-testid])',
);
await waitFor(() => (dock()?.querySelectorAll('.dv-groupview').length ?? 0) >= 3);
await waitFor(() => dock()?.getAttribute('data-layout') === 'stacked');
const groups = Array.from(dock()!.querySelectorAll<HTMLElement>('.dv-groupview'));
const boxes = groups.map((g) => g.getBoundingClientRect());
// One column: same left edge and width, each group strictly below the previous.
for (const box of boxes) {
expect(Math.round(box.left)).toBe(Math.round(boxes[0].left));
expect(Math.round(box.width)).toBe(Math.round(boxes[0].width));
}
for (let i = 1; i < boxes.length; i++) {
expect(boxes[i].top).toBeGreaterThanOrEqual(boxes[i - 1].bottom - 1);
}
expect(boxes[0].width).toBeGreaterThan(300);

host.style.width = '1100px';
await waitFor(() => dock()?.getAttribute('data-layout') === 'split');
await waitFor(() => {
const tops = Array.from(dock()!.querySelectorAll<HTMLElement>('.dv-groupview')).map((g) =>
Math.round(g.getBoundingClientRect().top),
);
return tops.length >= 3 && new Set(tops).size < tops.length;
});
}, 60_000);

it('loads every video element with crossOrigin="anonymous"', async () => {
await mount(1100);
await waitFor(() => host.querySelectorAll('video').length > 0);
const videos = Array.from(host.querySelectorAll('video'));
for (const video of videos) {
expect(video.getAttribute('crossorigin')).toBe('anonymous');
expect(video.crossOrigin).toBe('anonymous');
}
}, 60_000);

it('resizes the stacked sidebar with the keyboard', async () => {
const layout = await mount(500);
await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
Expand Down
15 changes: 14 additions & 1 deletion tests/visual-layout-order.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { classifyVisualFeatureKey, getAutoLayoutVisualRows, getVisualMaxPerRow } from '@/core';
import {
classifyVisualFeatureKey,
getAutoLayoutVisualRows,
getVisualMaxPerRow,
isStackedPanelsWidth,
} from '@/core';

describe('visual auto layout', () => {
it('with >=4 keys, groups by semantic bucket (head vs wrist) then packs rows', () => {
Expand Down Expand Up @@ -136,4 +141,12 @@ describe('visual auto layout', () => {
expect(getVisualMaxPerRow(900)).toBe(4);
expect(getVisualMaxPerRow(1280)).toBe(4);
});

it('stacks panels into one column under 600px of container width', () => {
expect(isStackedPanelsWidth(390)).toBe(true);
expect(isStackedPanelsWidth(599)).toBe(true);
expect(isStackedPanelsWidth(600)).toBe(false);
expect(isStackedPanelsWidth(1280)).toBe(false);
expect(isStackedPanelsWidth(0)).toBe(false);
});
});
Loading