diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index 43bcf0a5f..0af18f2c5 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -251,6 +251,32 @@ def _frame_image_cache_key(frame_id: int) -> str: return f"frame:{frame_id}:image" +UPLOADED_SCENE_PREFIX = "uploaded/" + + +def _configured_scene_ids(frame: Frame) -> set[str]: + scene_ids: set[str] = set() + for scene in frame.scenes or []: + scene_id = scene.get("id") if isinstance(scene, dict) else None + if isinstance(scene_id, str): + scene_ids.add(scene_id) + return scene_ids + + +def _scene_image_scene_id_for_frame(frame: Frame, scene_id: str) -> str: + if not scene_id.startswith(UPLOADED_SCENE_PREFIX): + return scene_id + + original_scene_id = scene_id[len(UPLOADED_SCENE_PREFIX) :] + configured_scene_ids = _configured_scene_ids(frame) + if scene_id in configured_scene_ids and original_scene_id not in configured_scene_ids: + return scene_id + if original_scene_id in configured_scene_ids: + return original_scene_id + + return original_scene_id + + def _coerce_frame_image_to_png(body: bytes, headers: dict[str, str]) -> bytes: content_type = headers.get("content-type", "").split(";", 1)[0].strip().lower() if content_type not in ("image/bmp", "image/x-ms-bmp"): @@ -1820,6 +1846,8 @@ async def api_frame_get_image( encoded_scene_id = await redis.get(f"frame:{id}:active_scene") if encoded_scene_id: scene_id = encoded_scene_id.decode("utf-8") + if scene_id: + scene_id = _scene_image_scene_id_for_frame(frame, scene_id) if scene_id: from app.models.scene_image import SceneImage from app.api.scene_images import _generate_thumbnail diff --git a/backend/app/api/tests/test_frames.py b/backend/app/api/tests/test_frames.py index 38c41fc6b..b9717d475 100644 --- a/backend/app/api/tests/test_frames.py +++ b/backend/app/api/tests/test_frames.py @@ -17,6 +17,7 @@ from app.models.frame import Frame from app.models.log import Log from app.models.metrics import Metrics +from app.models.scene_image import SceneImage from app.models.settings import Settings from app.models.user import User from app.tenancy import ensure_default_project_for_user @@ -586,6 +587,68 @@ async def mock_fetch(frame_obj, redis_obj, *, path, method="GET"): assert cached.startswith(b'\x89PNG') +@pytest.mark.asyncio +async def test_api_frame_get_image_caches_uploaded_preview_under_original_scene_id(async_client, db, redis): + frame = await new_frame(db, redis, 'UploadedPreviewImageFrame', 'localhost', 'localhost') + frame.scenes = [{'id': 'scene-1', 'name': 'Scene 1', 'nodes': [], 'edges': []}] + db.add(frame) + db.commit() + + png = io.BytesIO() + Image.new('RGB', (2, 1), 'white').save(png, format='PNG') + png_body = png.getvalue() + + async def mock_fetch(frame_obj, redis_obj, *, path, method="GET"): + return 200, png_body, {'content-type': 'image/png', 'x-scene-id': 'uploaded/scene-1'} + + with patch('app.api.frames._fetch_frame_http_bytes', side_effect=mock_fetch): + response = await async_client.get(f'/api/frames/{frame.id}/image?t=123') + + assert response.status_code == 200 + assert response.content == png_body + assert ( + db.query(SceneImage) + .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id='scene-1') + .first() + is not None + ) + assert ( + db.query(SceneImage) + .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id='uploaded/scene-1') + .first() + is None + ) + + +@pytest.mark.asyncio +async def test_api_frame_get_image_caches_unsaved_uploaded_preview_under_original_scene_id(async_client, db, redis): + frame = await new_frame(db, redis, 'UnsavedUploadedPreviewImageFrame', 'localhost', 'localhost') + + png = io.BytesIO() + Image.new('RGB', (2, 1), 'white').save(png, format='PNG') + png_body = png.getvalue() + + async def mock_fetch(frame_obj, redis_obj, *, path, method="GET"): + return 200, png_body, {'content-type': 'image/png', 'x-scene-id': 'uploaded/new-scene'} + + with patch('app.api.frames._fetch_frame_http_bytes', side_effect=mock_fetch): + response = await async_client.get(f'/api/frames/{frame.id}/image?t=123') + + assert response.status_code == 200 + assert ( + db.query(SceneImage) + .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id='new-scene') + .first() + is not None + ) + assert ( + db.query(SceneImage) + .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id='uploaded/new-scene') + .first() + is None + ) + + @pytest.mark.asyncio async def test_api_frame_get_image_does_not_share_host_port_cache_across_projects(async_client, db, redis): frame = await new_frame( diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png index 3ef9655f6..b5e6d41e1 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png index e50e5215c..72b6d0dd2 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png index af567c5ef..7b6233f5b 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png index 3f7b2c247..dfe0e7fc2 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png index 88e3d381e..00ef5d58f 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png index d0f1929d9..72dfceb8a 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--full.png index b3159b80c..80821e67f 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mid.png index 4489c6066..5e1a901f2 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mobile.png index b00313007..40bf6b919 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--full.png index 6a20ad76a..50db4490c 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mid.png index 7539d5b50..15793293e 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mobile.png index 31748e1bf..da704f796 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--expanded-scene--light--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mid.png index 6f6a7ca91..f91638120 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mobile.png index e87534800..d54131e13 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mid.png index 86dc0b1e6..3da5dc816 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mobile.png index cc612cf89..4a56f8d3e 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--preview-drawer--light--mobile.png differ diff --git a/e2e/frontend-visual/tests/visual-cases.ts b/e2e/frontend-visual/tests/visual-cases.ts index d9b43a322..462733917 100644 --- a/e2e/frontend-visual/tests/visual-cases.ts +++ b/e2e/frontend-visual/tests/visual-cases.ts @@ -108,7 +108,7 @@ async function expandDashboardScene(page: Page): Promise { .filter({ has: page.getByRole('heading', { name: /^Dashboard$/ }) }) .last() await sceneDrawer.getByRole('link', { name: /^Open editor$/ }).waitFor() - await sceneDrawer.getByRole('button', { name: /^Delete$/ }).waitFor() + await sceneDrawer.getByText(/^Scene control$/).waitFor() } async function fillLogsSearch(page: Page): Promise { diff --git a/frontend/package.json b/frontend/package.json index ad1c18315..d290a9391 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -90,7 +90,7 @@ "express": "^4.19.2", "fs-extra": "^11.2.0", "kea-test-utils": "^0.2.2", - "kea-typegen": "3.3.4", + "kea-typegen": "3.7.1", "postcss": "^8.4.27", "postcss-preset-env": "^9.5.13", "prettier": "^2.7.1", @@ -98,7 +98,7 @@ "sass-embedded": "^1.97.2", "tailwindcss": "^3.4.1", "ts-json-schema-generator": "^1.3.0", - "typescript": "5.8.3", + "typescript": "5.9.3", "vite": "^7.3.1" }, "overrides": { diff --git a/frontend/src/index.css b/frontend/src/index.css index e836518fd..df324e483 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -547,6 +547,194 @@ html[data-frameos-theme='dark'] .frameos-divider { border-color: rgba(203, 213, 225, 0.72); } +.frameos-split-thumbnail, +.frameos-split-preview { + background: rgba(241, 245, 249, 0.76); + border-color: rgba(203, 213, 225, 0.72); + --tw-ring-color: rgba(203, 213, 225, 0.68); +} + +.frameos-split-thumbnail-cell { + border-color: rgba(255, 255, 255, 0.9); +} + +.frameos-split-thumbnail-cell-a { + background: rgba(203, 213, 225, 0.95); +} + +.frameos-split-thumbnail-cell-b { + background: rgba(148, 163, 184, 0.92); +} + +.frameos-split-thumbnail-cell-c { + background: rgba(226, 232, 240, 0.95); +} + +.frameos-split-preset-button { + --tw-ring-color: var(--frameos-primary-ring); +} + +.frameos-split-preset-button-selected { + border-color: var(--frameos-primary-border-strong); +} + +.frameos-split-cell { + background: rgba(241, 245, 249, 0.82); + border-color: rgba(255, 255, 255, 0.92); +} + +.frameos-split-cell-selected { + z-index: 10; + border-color: transparent; +} + +.frameos-split-cell-selected::after { + content: ''; + position: absolute; + inset: 0; + z-index: 25; + pointer-events: none; + border: 4px solid var(--frameos-primary); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.78), 0 0 0 2px var(--frameos-primary-ring-soft); +} + +.frameos-split-cell-empty { + background-color: rgba(248, 250, 252, 0.28); + background-image: repeating-linear-gradient( + 135deg, + rgba(248, 250, 252, 0.42) 0, + rgba(248, 250, 252, 0.42) 8px, + rgba(148, 163, 184, 0.2) 8px, + rgba(148, 163, 184, 0.2) 16px + ); + box-shadow: inset 0 0 0 2px rgba(15, 23, 42, 0.26), inset 0 0 0 9999px rgba(255, 255, 255, 0.06); +} + +.frameos-split-drop-label { + display: inline-flex; + max-width: 100%; + align-items: center; + justify-content: center; + border: 2px solid rgba(15, 23, 42, 0.82); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.28), 0 0 0 1px rgba(255, 255, 255, 0.88), + inset 0 1px 0 rgba(255, 255, 255, 0.82); + color: #0f172a; + line-height: 1; + padding: 0.4rem 0.7rem; +} + +.frameos-split-cell-label { + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 -1px 0 rgba(148, 163, 184, 0.28); +} + +.frameos-split-divider { + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(148, 163, 184, 0.5); +} + +.frameos-split-divider:hover, +.frameos-split-divider:focus-visible { + background: #ffffff; + border-color: var(--frameos-primary-border-strong); +} + +.frameos-scene-source-title { + display: -webkit-box; + min-height: 2.85rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.frameos-theme-dark .frameos-split-thumbnail, +.frameos-theme-dark .frameos-split-preview, +html[data-frameos-theme='dark'] .frameos-split-thumbnail, +html[data-frameos-theme='dark'] .frameos-split-preview { + background: rgba(22, 24, 29, 0.78); + border-color: rgba(255, 255, 255, 0.1); + --tw-ring-color: rgba(255, 255, 255, 0.08); +} + +.frameos-theme-dark .frameos-split-thumbnail-cell, +html[data-frameos-theme='dark'] .frameos-split-thumbnail-cell { + border-color: rgba(255, 255, 255, 0.14); +} + +.frameos-theme-dark .frameos-split-thumbnail-cell-a, +html[data-frameos-theme='dark'] .frameos-split-thumbnail-cell-a { + background: rgba(64, 68, 78, 0.92); +} + +.frameos-theme-dark .frameos-split-thumbnail-cell-b, +html[data-frameos-theme='dark'] .frameos-split-thumbnail-cell-b { + background: rgba(82, 88, 102, 0.92); +} + +.frameos-theme-dark .frameos-split-thumbnail-cell-c, +html[data-frameos-theme='dark'] .frameos-split-thumbnail-cell-c { + background: rgba(45, 49, 58, 0.95); +} + +.frameos-theme-dark .frameos-split-cell, +html[data-frameos-theme='dark'] .frameos-split-cell { + background: rgba(22, 24, 29, 0.82); + border-color: rgba(255, 255, 255, 0.14); +} + +.frameos-theme-dark .frameos-split-cell-selected, +html[data-frameos-theme='dark'] .frameos-split-cell-selected { + border-color: transparent; +} + +.frameos-theme-dark .frameos-split-cell-selected::after, +html[data-frameos-theme='dark'] .frameos-split-cell-selected::after { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3), 0 0 0 2px var(--frameos-primary-ring-soft); +} + +.frameos-theme-dark .frameos-split-cell-empty, +html[data-frameos-theme='dark'] .frameos-split-cell-empty { + background-color: rgba(15, 23, 42, 0.28); + background-image: repeating-linear-gradient( + 135deg, + rgba(15, 23, 42, 0.42) 0, + rgba(15, 23, 42, 0.42) 8px, + rgba(148, 163, 184, 0.18) 8px, + rgba(148, 163, 184, 0.18) 16px + ); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.34), inset 0 0 0 9999px rgba(0, 0, 0, 0.12); +} + +.frameos-theme-dark .frameos-split-drop-label, +html[data-frameos-theme='dark'] .frameos-split-drop-label { + border-color: rgba(255, 255, 255, 0.82); + background: rgba(15, 23, 42, 0.94); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.52), 0 0 0 1px rgba(15, 23, 42, 0.84), inset 0 1px 0 rgba(255, 255, 255, 0.16); + color: #f8fafc; +} + +.frameos-theme-dark .frameos-split-cell-label, +html[data-frameos-theme='dark'] .frameos-split-cell-label { + background: rgba(15, 23, 42, 0.78); + box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.08); +} + +.frameos-theme-dark .frameos-split-divider, +html[data-frameos-theme='dark'] .frameos-split-divider { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.24); +} + +.frameos-theme-dark .frameos-split-divider:hover, +.frameos-theme-dark .frameos-split-divider:focus-visible, +html[data-frameos-theme='dark'] .frameos-split-divider:hover, +html[data-frameos-theme='dark'] .frameos-split-divider:focus-visible { + background: rgba(255, 255, 255, 0.22); + border-color: var(--frameos-primary-border-strong); +} + .frameos-add-scene-hover:hover { background: rgba(255, 255, 255, 0.8); } diff --git a/frontend/src/scenes/frame/frameLogic.ts b/frontend/src/scenes/frame/frameLogic.ts index c917775b0..6f53eaaf0 100644 --- a/frontend/src/scenes/frame/frameLogic.ts +++ b/frontend/src/scenes/frame/frameLogic.ts @@ -5,6 +5,7 @@ import type { frameLogicType } from './frameLogicType' import { subscriptions } from 'kea-subscriptions' import { AppNodeData, + DiagramEdge, DiagramNode, FrameErrorBehavior, FrameScene, @@ -45,6 +46,14 @@ import { urls } from '../../urls' import { normalizeFrameCompilationMode } from '../../utils/frameBuildOptions' import { frameHasActivityLog } from '../../decorators/frame' import { frameRunsScenesInterpreted, sceneExecutionForFrame } from '../../utils/sceneExecution' +import { + cloneSplitScreenSceneLayout, + defaultSplitScreenBackground, + type SplitLayoutBranch, + type SplitLayoutNode, + type SplitScreenBackground, + type SplitScreenSceneLayout, +} from '../../utils/splitScreenLayouts' export type { ChangeDetail, DeployPlanResponse, DeployRecommendation, SummaryItem } from './frameDeployUtils' @@ -1119,6 +1128,178 @@ function buildBlankScene(frame: Partial, name: string = 'New blank sc ) } +function splitRatioString(ratios: number[], length: number): string { + return Array.from({ length }, (_, index) => { + const ratio = Number(ratios[index]) + return Number.isFinite(ratio) && ratio > 0 ? Number(ratio.toFixed(3)).toString() : '1' + }).join(' ') +} + +function splitLayoutBorderWidth(layout: SplitScreenSceneLayout): number { + return Math.max(0, Math.min(48, Math.round(Number(layout.borderWidth) || 0))) +} + +function splitLayoutOuterBorderWidth(layout: SplitScreenSceneLayout): number { + const value = layout.outerBorderWidth ?? ((layout as any).outerBorder ? layout.borderWidth : 0) + return Math.max(0, Math.min(48, Math.round(Number(value) || 0))) +} + +function splitLayoutBackground(layout: SplitScreenSceneLayout): SplitScreenBackground { + return { + ...defaultSplitScreenBackground, + ...(layout.background ?? {}), + opacity: Math.max(0, Math.min(1, Number(layout.background?.opacity ?? defaultSplitScreenBackground.opacity) || 0)), + } +} + +function splitNodeConfig(branch: SplitLayoutBranch, gapWidth: number, outerBorderWidth: number): Record { + const rows = branch.direction === 'column' ? branch.children.length : 1 + const columns = branch.direction === 'row' ? branch.children.length : 1 + return { + rows: String(rows), + columns: String(columns), + hideEmpty: false, + gap: String(gapWidth), + margin: String(outerBorderWidth), + ...(branch.direction === 'row' + ? { width_ratios: splitRatioString(branch.ratios, branch.children.length) } + : { height_ratios: splitRatioString(branch.ratios, branch.children.length) }), + } +} + +export function buildSplitScene( + frame: Partial, + layout: SplitScreenSceneLayout, + sceneId?: string | null +): FrameScene { + const nodes: DiagramNode[] = [] + const edges: DiagramEdge[] = [] + let visualIndex = 0 + const borderWidth = splitLayoutBorderWidth(layout) + const outerBorderWidth = splitLayoutOuterBorderWidth(layout) + const background = splitLayoutBackground(layout) + + const eventNode: DiagramNode = { + id: uuidv4(), + type: 'event', + position: { x: 121, y: 113 }, + data: { keyword: 'render' }, + } + nodes.push(eventNode) + + const addEdge = (source: string, sourceHandle: string, target: string, targetHandle = 'prev'): void => { + edges.push({ + id: uuidv4(), + source, + sourceHandle, + target, + targetHandle, + type: 'appNodeEdge', + }) + } + + const addBackgroundNode = (): { firstNodeId: string; lastNodeId: string } | null => { + let firstNodeId: string | null = null + let lastNodeId: string | null = null + + if (background.sceneId) { + const nodeId = uuidv4() + nodes.push({ + id: nodeId, + type: 'scene', + position: { x: 390, y: -70 }, + data: { keyword: background.sceneId, config: {} } satisfies SceneNodeData, + }) + firstNodeId = nodeId + lastNodeId = nodeId + } + + if (firstNodeId && lastNodeId && background.opacity < 1) { + const opacityNodeId = uuidv4() + nodes.push({ + id: opacityNodeId, + type: 'app', + position: { x: 390, y: 20 }, + data: { + keyword: 'render/opacity', + name: 'Background opacity', + config: { opacity: background.opacity }, + } satisfies AppNodeData, + }) + addEdge(lastNodeId, 'next', opacityNodeId) + lastNodeId = opacityNodeId + } + + return firstNodeId && lastNodeId ? { firstNodeId, lastNodeId } : null + } + + const addSceneNode = (child: SplitLayoutNode, depth: number): string | null => { + if (child.type !== 'leaf' || !child.sceneId) { + return null + } + const nodeId = uuidv4() + nodes.push({ + id: nodeId, + type: 'scene', + position: { x: 760 + depth * 320, y: 120 + visualIndex * 110 }, + data: { keyword: child.sceneId, config: { ...(child.state ?? {}) } } satisfies SceneNodeData, + }) + visualIndex += 1 + return nodeId + } + + const addSplitNode = (branch: SplitLayoutBranch, depth: number): string => { + const nodeId = uuidv4() + nodes.push({ + id: nodeId, + type: 'app', + position: { x: 400 + depth * 320, y: 100 + visualIndex * 40 }, + data: { + keyword: 'render/split', + name: depth === 0 ? 'Split screen' : 'Nested split', + config: splitNodeConfig(branch, borderWidth, depth === 0 ? outerBorderWidth : 0), + } satisfies AppNodeData, + }) + + branch.children.forEach((child, index) => { + const targetNodeId = child.type === 'split' ? addSplitNode(child, depth + 1) : addSceneNode(child, depth + 1) + if (!targetNodeId) { + return + } + const row = branch.direction === 'column' ? index + 1 : 1 + const column = branch.direction === 'row' ? index + 1 : 1 + addEdge(nodeId, `field/render_functions[${row}][${column}]`, targetNodeId) + }) + + return nodeId + } + + const rootNodeId = addSplitNode(layout.root, 0) + const backgroundNodes = addBackgroundNode() + if (backgroundNodes) { + addEdge(eventNode.id, 'next', backgroundNodes.firstNodeId) + addEdge(backgroundNodes.lastNodeId, 'next', rootNodeId) + } else { + addEdge(eventNode.id, 'next', rootNodeId) + } + + return sanitizeScene( + { + id: sceneId || uuidv4(), + name: layout.name || 'Split screen', + nodes, + edges, + fields: [], + settings: { + backgroundColor: background.color, + execution: 'interpreted', + splitScreenLayout: cloneSplitScreenSceneLayout(layout) as unknown as Record, + }, + }, + frame + ) +} + async function saveFrameForm(frame: Partial, frameId: number, nextAction: FrameNextAction): Promise { const normalizedFrame = normalizeFrameForSubmit(frame) const json = buildDeployPlanRequestBody(normalizedFrame, frameSubmitKeys(normalizedFrame)) @@ -1679,10 +1860,7 @@ export const frameLogic = kea([ } else { await asyncActions.submitFrameForm() } - framesModel.actions.deployFrame( - props.frameId, - frameCanUseFastDeploy(values.frame, values.requiresRecompilation) - ) + framesModel.actions.deployFrame(props.frameId, frameCanUseFastDeploy(values.frame, values.requiresRecompilation)) }, saveAndFastDeployFrame: async () => { const frameForm = preferSshTransportWhenRemoteUnavailable(values.frameForm, values.remoteDeployConnected) @@ -1711,10 +1889,7 @@ export const frameLogic = kea([ rebootFrame: () => framesModel.actions.rebootFrame(props.frameId), stopFrame: () => framesModel.actions.stopFrame(props.frameId), deployFrame: () => { - framesModel.actions.deployFrame( - props.frameId, - frameCanUseFastDeploy(values.frame, values.requiresRecompilation) - ) + framesModel.actions.deployFrame(props.frameId, frameCanUseFastDeploy(values.frame, values.requiresRecompilation)) }, fastDeployFrame: () => framesModel.actions.deployFrame(props.frameId, true), fullDeployFrame: () => framesModel.actions.deployFrame(props.frameId, false), diff --git a/frontend/src/scenes/frame/panels/Scenes/ExpandedScene.tsx b/frontend/src/scenes/frame/panels/Scenes/ExpandedScene.tsx index af59b024e..25a99c2cf 100644 --- a/frontend/src/scenes/frame/panels/Scenes/ExpandedScene.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/ExpandedScene.tsx @@ -22,7 +22,14 @@ export interface ExpandedSceneProps { isUndeployed?: boolean } -export function ExpandedScene({ frameId, sceneId, scene, showEditButton = true, isUndeployed }: ExpandedSceneProps) { +export function ExpandedScene({ + frameId, + sceneId, + scene, + showEditButton = true, + isUnsaved, + isUndeployed, +}: ExpandedSceneProps) { const { stateChanges, hasStateChanges, fields } = useValues(expandedSceneLogic({ frameId, sceneId, scene })) const { states, sceneId: currentSceneId } = useValues(controlLogic({ frameId })) const { requiresRecompilation, changedScenes } = useValues(frameLogic({ frameId })) @@ -34,7 +41,8 @@ export function ExpandedScene({ frameId, sceneId, scene, showEditButton = true, const currentState = states[sceneId] ?? {} const sceneIsUndeployed = isUndeployed ?? undeployedSceneIds.has(sceneId) - const sceneHasChanges = changedScenes.has(sceneId) || sceneIsUndeployed + const sceneIsUnsaved = isUnsaved ?? changedScenes.has(sceneId) + const sceneHasChanges = sceneIsUnsaved || sceneIsUndeployed const canPreviewUnsavedChanges = sceneHasChanges const activateLabel = sceneIsUndeployed && sceneId !== currentSceneId @@ -42,7 +50,7 @@ export function ExpandedScene({ frameId, sceneId, scene, showEditButton = true, : sceneId === currentSceneId ? 'Update active scene' : 'Activate scene' - const previewLabel = sceneIsUndeployed ? 'Preview undeployed scene' : 'Preview unsaved scene' + const previewLabel = 'Preview scene with changes' const buildNextState = (): Record => { const desiredState = { ...currentState, ...stateChanges } diff --git a/frontend/src/scenes/frame/panels/Scenes/scenesLogic.tsx b/frontend/src/scenes/frame/panels/Scenes/scenesLogic.tsx index 7551210b7..bd5509b8c 100644 --- a/frontend/src/scenes/frame/panels/Scenes/scenesLogic.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/scenesLogic.tsx @@ -43,6 +43,52 @@ const applyStateToSceneFields = (scene: FrameScene, state: Record | return { ...scene, fields } } +function referencedSceneIds(scene: FrameScene): string[] { + const sceneIds = new Set() + + for (const node of scene.nodes ?? []) { + const data = node.data as any + if (node.type === 'scene' && typeof data?.keyword === 'string' && data.keyword) { + sceneIds.add(data.keyword) + } else if (node.type === 'dispatch' && data?.keyword === 'setCurrentScene') { + const targetSceneId = data?.config?.sceneId + if (typeof targetSceneId === 'string' && targetSceneId) { + sceneIds.add(targetSceneId) + } + } + } + + return Array.from(sceneIds) +} + +function collectScenePreviewPayloadScenes( + rootScene: FrameScene, + scenes: FrameScene[], + resolvedState: Record | null +): FrameScene[] { + const sceneById = new Map(scenes.map((scene) => [scene.id, scene])) + const result: FrameScene[] = [] + const visited = new Set() + + const visit = (scene: FrameScene, isRoot = false): void => { + if (visited.has(scene.id)) { + return + } + visited.add(scene.id) + result.push(isRoot ? applyStateToSceneFields(scene, resolvedState) : scene) + + for (const referencedSceneId of referencedSceneIds(scene)) { + const referencedScene = sceneById.get(referencedSceneId) + if (referencedScene) { + visit(referencedScene) + } + } + } + + visit(rootScene, true) + return result +} + export const scenesLogic = kea([ path(['src', 'scenes', 'frame', 'panels', 'Scenes', 'scenesLogic']), props({} as ScenesLogicProps), @@ -640,9 +686,9 @@ export const scenesLogic = kea([ }) try { const resolvedState = state ?? values.states?.[scene.id] ?? values.states?.[`uploaded/${scene.id}`] ?? null - const payloadScene = applyStateToSceneFields(scene, resolvedState) + const payloadScenes = collectScenePreviewPayloadScenes(scene, values.scenes, resolvedState) const payload = { - scenes: [payloadScene], + scenes: payloadScenes, sceneId: scene.id, ...(resolvedState && Object.keys(resolvedState).length > 0 ? { state: resolvedState } : {}), } diff --git a/frontend/src/scenes/workspace/FrameosShell.tsx b/frontend/src/scenes/workspace/FrameosShell.tsx index 974450903..aa7d491ee 100644 --- a/frontend/src/scenes/workspace/FrameosShell.tsx +++ b/frontend/src/scenes/workspace/FrameosShell.tsx @@ -3,6 +3,7 @@ import { BindLogic, useActions, useMountedLogic, useValues } from 'kea' import clsx from 'clsx' import { useEffect, useState, type CSSProperties, type MouseEvent } from 'react' import { + ArrowLeftIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, @@ -193,15 +194,17 @@ function WorkspaceChatDrawer({ frameId, nodeId, sceneId, + source, }: { frameId: number nodeId?: string | null sceneId: string | null + source?: 'templates' | null }): JSX.Element | null { useMountedLogic(chatLogic({ frameId, sceneId })) useMountedLogic(workspaceChatDrawerLogic({ frameId, nodeId, sceneId })) const { frames } = useValues(framesModel) - const { closeChatDrawer } = useActions(workspaceLogic) + const { closeChatDrawer, openTemplateDrawer } = useActions(workspaceLogic) const frame = frames[frameId] const frameLogicProps = { frameId } @@ -215,10 +218,23 @@ function WorkspaceChatDrawer({
-
-

AI chat

-
- {frame.name || frameHost(frame)} +
+ {source === 'templates' ? ( + + ) : null} +
+

AI chat

+
+ {frame.name || frameHost(frame)} +
-
- - -
+ {generatorOpen ? ( + + ) : ( +
+ + +
+ )}
@@ -488,7 +509,8 @@ export function TemplateDrawer(): JSX.Element | null { function AddSceneDrawerActions({ frame }: { frame: FrameType }): JSX.Element { const { createBlankSceneAndSave } = useActions(frameLogic({ frameId: frame.id })) - const { openChatDrawer } = useActions(workspaceLogic) + const { openGenerator } = useActions(splitScreenLayoutLogic({ frameId: frame.id })) + const hasScenes = (frame.scenes?.length ?? 0) > 0 return (
@@ -503,14 +525,39 @@ function AddSceneDrawerActions({ frame }: { frame: FrameType }): JSX.Element { - New blank scene - Start with a render event + New blank scene + Start with a render event +
@@ -650,19 +697,28 @@ export function SceneControlPanel(): JSX.Element | null { } function resolveSceneControlSelection( - frame: FrameType, + savedFrame: FrameType, + editingFrame: Partial, sceneId: string, uploadedScenes: FrameScene[] ): { scene: FrameScene | null; sceneId: string; saved: boolean } { const uploadedSceneId = sceneId.startsWith(uploadedScenePrefix) ? sceneId.slice(uploadedScenePrefix.length) : null const savedScene = - frame.scenes?.find((candidate) => candidate.id === sceneId) ?? - (uploadedSceneId ? frame.scenes?.find((candidate) => candidate.id === uploadedSceneId) : null) + savedFrame.scenes?.find((candidate) => candidate.id === sceneId) ?? + (uploadedSceneId ? savedFrame.scenes?.find((candidate) => candidate.id === uploadedSceneId) : null) if (savedScene) { return { scene: savedScene, sceneId: savedScene.id, saved: true } } + const editingScene = + editingFrame.scenes?.find((candidate) => candidate.id === sceneId) ?? + (uploadedSceneId ? editingFrame.scenes?.find((candidate) => candidate.id === uploadedSceneId) : null) + + if (editingScene) { + return { scene: editingScene, sceneId: editingScene.id, saved: false } + } + const uploadedScene = uploadedSceneId ? uploadedScenes.find((candidate) => candidate.id === uploadedSceneId) ?? null : uploadedScenes.find((candidate) => candidate.id === sceneId) ?? null @@ -676,19 +732,30 @@ function SceneControlPanelContent({ sceneControlSelection: { frameId: number; sceneId: string } }): JSX.Element | null { const { frames } = useValues(framesModel) - const { closeSceneControl } = useActions(workspaceLogic) + const { closeSceneControl, openTemplateDrawer } = useActions(workspaceLogic) const frame = frames[sceneControlSelection.frameId] const { sceneId: currentSceneId, uploadedScenes, uploadedScenesLoading, } = useValues(controlLogic({ frameId: sceneControlSelection.frameId })) + const frameLogicProps = { frameId: sceneControlSelection.frameId } + const { frameForm, undeployedChanges, unsavedChanges } = useValues(frameLogic(frameLogicProps)) + const { saveAndDeployFrame, saveFrame } = useActions(frameLogic(frameLogicProps)) + const { undeployedSceneIds, unsavedSceneIds } = useValues(scenesLogic(frameLogicProps)) + const { openGenerator } = useActions(splitScreenLayoutLogic(frameLogicProps)) if (!frame) { return null } - const { scene, sceneId, saved } = resolveSceneControlSelection(frame, sceneControlSelection.sceneId, uploadedScenes) + const editingFrame = { ...frame, ...(frameForm ?? {}) } as Partial + const { scene, sceneId, saved } = resolveSceneControlSelection( + frame, + editingFrame, + sceneControlSelection.sceneId, + uploadedScenes + ) if (!scene) { return ( @@ -717,8 +784,19 @@ function SceneControlPanelContent({ ) } - const frameLogicProps = { frameId: frame.id } const selectedSceneIsActive = sceneIsActive(scene, currentSceneId) + const sceneIsEditable = Boolean(editingFrame.scenes?.some((candidate) => candidate.id === sceneId)) + const sceneIsUnsaved = sceneIsEditable && (!saved || unsavedSceneIds.has(sceneId)) + const sceneIsUndeployed = sceneIsEditable && (!saved || undeployedSceneIds.has(sceneId)) + const splitLayout = normalizeSplitScreenSceneLayout(scene.settings?.splitScreenLayout) + + const handleEditSplit = (): void => { + if (!splitLayout) { + return + } + openTemplateDrawer(frame.id) + openGenerator(scene.id, splitLayout) + } return (
@@ -782,21 +860,39 @@ function SceneControlPanelContent({ /> Open editor - + {splitLayout ? ( + + ) : null}
) : null} +
@@ -814,26 +910,56 @@ function SceneControlPanelModeTitle(): JSX.Element { ) } -function DeleteInstalledSceneButton({ frame, scene }: { frame: FrameType; scene: FrameScene }): JSX.Element { - const { deleteSceneAndSave } = useActions(frameLogic({ frameId: frame.id })) - const { closeSceneControl } = useActions(workspaceLogic) +function SceneControlChangeNotice({ + frameUndeployedChanges, + frameUnsavedChanges, + sceneIsUndeployed, + sceneIsUnsaved, + onDeploy, + onSave, +}: { + frameUndeployedChanges: boolean + frameUnsavedChanges: boolean + sceneIsUndeployed: boolean + sceneIsUnsaved: boolean + onDeploy: () => void + onSave: () => void +}): JSX.Element | null { + if (!sceneIsUnsaved && !sceneIsUndeployed) { + return null + } + + const statusText = sceneIsUnsaved + ? sceneIsUndeployed + ? 'This scene has unsaved changes that are not deployed to the frame.' + : 'This scene has unsaved changes.' + : 'This scene is saved but not deployed to the frame.' return ( - +
+
{statusText}
+
+ {sceneIsUnsaved ? ( + + ) : null} + {sceneIsUnsaved || sceneIsUndeployed || frameUnsavedChanges || frameUndeployedChanges ? ( + + ) : null} +
+
) } diff --git a/frontend/src/scenes/workspace/SplitScreenLayoutDrawer.tsx b/frontend/src/scenes/workspace/SplitScreenLayoutDrawer.tsx new file mode 100644 index 000000000..9648ec0ce --- /dev/null +++ b/frontend/src/scenes/workspace/SplitScreenLayoutDrawer.tsx @@ -0,0 +1,905 @@ +import { useActions, useValues } from 'kea' +import clsx from 'clsx' +import { useRef, type DragEvent, type KeyboardEvent, type MouseEvent, type PointerEvent, type RefObject } from 'react' +import { ArrowLeftIcon, EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/react/24/outline' +import { ColorInput } from '../../components/ColorInput' +import { FrameImage } from '../../components/FrameImage' +import { entityImagesModel } from '../../models/entityImagesModel' +import type { FrameScene, FrameType, StateField } from '../../types' +import { buildSplitScene, frameLogic } from '../frame/frameLogic' +import { StateFieldEdit } from '../frame/panels/Scenes/StateFieldEdit' +import { apiFetch } from '../../utils/apiFetch' +import { buildSplitScreenThumbnail } from '../../utils/splitScreenThumbnail' +import { + assignSceneToSplitLayoutLeaf, + splitLayoutLeafBorderEdges, + splitLayoutDividers, + splitLayoutLeafRects, + splitLayoutLeaves, + splitLayoutOuterBorderEdges, + splitScreenLayoutPresets, + type SplitLayoutDivider, + type SplitLayoutLeaf, + type SplitLayoutLeafBorderEdges, + type SplitLayoutLeafRect, + type SplitLayoutNode, + type SplitScreenBackground, + type SplitScreenSceneLayout, +} from '../../utils/splitScreenLayouts' +import { getFrameosSceneDragData, hasFrameosSceneDragData, setFrameosSceneDragData } from './sceneDrag' +import { splitScreenLayoutLogic } from './splitScreenLayoutLogic' +import { workspaceLogic } from './workspaceLogic' + +const DEFAULT_SPLIT_SCENE_NAME = 'Split screen' +const INITIAL_SPLIT_PRESET_COUNT = 3 +const PREVIEW_MAX_HEIGHT_VH = 50 + +function frameAspectRatio(frame: FrameType): string { + if (!frame.width || !frame.height) { + return '16 / 10' + } + return frame.rotate === 90 || frame.rotate === 270 + ? `${frame.height} / ${frame.width}` + : `${frame.width} / ${frame.height}` +} + +function frameAspectValue(frame: FrameType): number { + if (!frame.width || !frame.height) { + return 16 / 10 + } + const width = frame.rotate === 90 || frame.rotate === 270 ? frame.height : frame.width + const height = frame.rotate === 90 || frame.rotate === 270 ? frame.width : frame.height + return width > 0 && height > 0 ? width / height : 16 / 10 +} + +function sceneById(frame: FrameType): Map { + return new Map((frame.scenes ?? []).map((scene) => [scene.id, scene])) +} + +function sceneTitlePart(scene: FrameScene): string { + const title = (scene.name || 'Untitled').trim() || 'Untitled' + const words = title.split(/\s+/) + if (title.length <= 18 && words.length <= 2) { + return title + } + return words[0] || title +} + +function joinedTitle(parts: string[]): string { + if (parts.length === 0) { + return DEFAULT_SPLIT_SCENE_NAME + } + if (parts.length === 1) { + return `${parts[0]} split` + } + if (parts.length === 2) { + return `${parts[0]} & ${parts[1]}` + } + if (parts.length === 3) { + return `${parts[0]}, ${parts[1]} & ${parts[2]}` + } + return `${parts[0]}, ${parts[1]} & ${parts.length - 2} more` +} + +function suggestedSplitSceneTitle(layout: SplitScreenSceneLayout, scenes: Map): string { + const parts: string[] = [] + for (const leaf of splitLayoutLeaves(layout.root)) { + const scene = leaf.sceneId ? scenes.get(leaf.sceneId) : null + if (!scene) { + continue + } + const part = sceneTitlePart(scene) + if (!parts.includes(part)) { + parts.push(part) + } + } + return joinedTitle(parts) +} + +function previewMaxWidth(frame: FrameType): string { + return `min(100%, ${(frameAspectValue(frame) * PREVIEW_MAX_HEIGHT_VH).toFixed(3)}vh)` +} + +function LayoutThumbnail({ root }: { root: SplitLayoutNode }): JSX.Element { + const rects = splitLayoutLeafRects(root) + + return ( + + {rects.map((rect, index) => ( + + ))} + + ) +} + +function MoreLayoutsButton({ onClick }: { onClick: () => void }): JSX.Element { + return ( + + ) +} + +function SceneSourceStrip({ + frame, + onPickScene, + onSearchChange, + search, +}: { + frame: FrameType + onPickScene: (sceneId: string) => void + onSearchChange: (search: string) => void + search: string +}): JSX.Element { + const scenes = frame.scenes ?? [] + const searchTerm = search.trim().toLowerCase() + const filteredScenes = searchTerm + ? scenes.filter((scene) => `${scene.name || ''} ${scene.id}`.toLowerCase().includes(searchTerm)) + : scenes + + return ( +
+
+
Scenes
+ onSearchChange(event.target.value)} + placeholder="Search" + className="frameos-form-control h-7 min-w-0 flex-1 rounded-md border px-2 text-xs font-medium outline-none transition focus:ring-1 focus:ring-blue-400 sm:max-w-44" + /> +
+ {scenes.length === 0 ? ( +
+ No scenes available +
+ ) : filteredScenes.length === 0 ? ( +
+ No matching scenes +
+ ) : ( +
+ {filteredScenes.map((scene) => ( + + ))} +
+ )} +
+ ) +} + +async function saveSplitScreenThumbnail( + frame: FrameType, + layout: SplitScreenSceneLayout, + sceneId: string, + updateEntityImage: (entity: string | null, subentity: string, force?: boolean) => void +): Promise { + const thumbnail = await buildSplitScreenThumbnail(frame, layout).catch(() => null) + if (!thumbnail) { + return + } + + try { + const response = await apiFetch(`/api/frames/${frame.id}/scene_images/${sceneId}`, { + method: 'POST', + body: thumbnail, + }) + if (response.ok) { + updateEntityImage(`frames/${frame.id}`, `scene_images/${sceneId}`) + } + } catch (error) { + console.error('Failed to save generated split scene thumbnail', error) + } +} + +function SplitPreviewCell({ + borderEdges, + borderWidth, + frame, + outerBorderWidth, + rect, + scene, + selected, + onDropScene, + onRemoveScene, + onSelect, +}: { + borderEdges: SplitLayoutLeafBorderEdges + borderWidth: number + frame: FrameType + outerBorderWidth: number + rect: SplitLayoutLeafRect + scene: FrameScene | null + selected: boolean + onDropScene: (leafId: string, sceneId: string) => void + onRemoveScene: (leafId: string) => void + onSelect: (leafId: string) => void +}): JSX.Element { + const handleDragOver = (event: DragEvent): void => { + if (!hasFrameosSceneDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDrop = (event: DragEvent): void => { + const sceneId = getFrameosSceneDragData(event.dataTransfer) + if (!sceneId) { + return + } + event.preventDefault() + onSelect(rect.leafId) + onDropScene(rect.leafId, sceneId) + } + + const handleRemove = (event: MouseEvent): void => { + event.stopPropagation() + onSelect(rect.leafId) + onRemoveScene(rect.leafId) + } + + const halfBorderWidth = Math.max(0, borderWidth) / 2 + const outerEdges = outerBorderWidth > 0 ? splitLayoutOuterBorderEdges(rect) : null + const paddingTop = borderEdges.top ? halfBorderWidth : outerEdges?.top ? outerBorderWidth : 0 + const paddingRight = borderEdges.right ? halfBorderWidth : outerEdges?.right ? outerBorderWidth : 0 + const paddingBottom = borderEdges.bottom ? halfBorderWidth : outerEdges?.bottom ? outerBorderWidth : 0 + const paddingLeft = borderEdges.left ? halfBorderWidth : outerEdges?.left ? outerBorderWidth : 0 + + return ( +
onSelect(rect.leafId)} + onDragOver={handleDragOver} + onDrop={handleDrop} + className={clsx( + 'frameos-split-cell-shell absolute cursor-pointer transition', + selected && 'frameos-split-cell-selected' + )} + style={{ + height: `${rect.height}%`, + left: `${rect.x}%`, + paddingBottom: `${paddingBottom}px`, + paddingLeft: `${paddingLeft}px`, + paddingRight: `${paddingRight}px`, + paddingTop: `${paddingTop}px`, + top: `${rect.y}%`, + width: `${rect.width}%`, + }} + > +
+ {scene ? ( + <> + +
+ {scene.name || 'Untitled scene'} +
+ + + ) : ( +
+ Drop scene +
+ )} +
+
+ ) +} + +function SplitRenderControls({ + background, + borderWidth, + frame, + outerBorderWidth, + scenes, + onSetBackgroundColor, + onSetBackgroundOpacity, + onSetBackgroundScene, + onSetBorderWidth, + onSetOuterBorderWidth, +}: { + background: SplitScreenBackground + borderWidth: number + frame: FrameType + outerBorderWidth: number + scenes: FrameScene[] + onSetBackgroundColor: (color: string) => void + onSetBackgroundOpacity: (opacity: number) => void + onSetBackgroundScene: (sceneId: string | null) => void + onSetBorderWidth: (borderWidth: number) => void + onSetOuterBorderWidth: (outerBorderWidth: number) => void +}): JSX.Element { + const backgroundScene = background.sceneId ? scenes.find((scene) => scene.id === background.sceneId) ?? null : null + const hasBackgroundScene = Boolean(background.sceneId) + + const handleSceneDragOver = (event: DragEvent): void => { + if (!hasFrameosSceneDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleSceneDrop = (event: DragEvent): void => { + const sceneId = getFrameosSceneDragData(event.dataTransfer) + if (!sceneId || !scenes.some((scene) => scene.id === sceneId)) { + return + } + event.preventDefault() + onSetBackgroundScene(sceneId) + } + + const handleRemoveBackgroundScene = (event: MouseEvent): void => { + event.preventDefault() + event.stopPropagation() + onSetBackgroundScene(null) + } + + return ( +
+
+ + Gap + {borderWidth}px + + onSetBorderWidth(Number(event.target.value))} + className="w-full accent-[var(--frameos-primary)]" + /> + + Border + {outerBorderWidth}px + + onSetOuterBorderWidth(Number(event.target.value))} + className="w-full accent-[var(--frameos-primary)]" + /> +
+ + + +
+
+ Background scene + {hasBackgroundScene ? ( + + ) : null} +
+
+ {background.sceneId ? ( + <> + + + {backgroundScene?.name || 'Background scene'} + + + ) : ( +
+ Drop scene +
+ )} +
+ {hasBackgroundScene ? ( + + ) : null} +
+
+ ) +} + +function SplitSceneOptionsPanel({ + leaf, + scene, + onSetSceneStateValue, +}: { + leaf: SplitLayoutLeaf | null + scene: FrameScene | null + onSetSceneStateValue: (leafId: string, field: StateField, value: any) => void +}): JSX.Element { + if (!leaf) { + return ( +
+ Click a scene panel to set its options. +
+ ) + } + + if (!leaf.sceneId) { + return ( +
+ Drop a scene into the selected panel before setting options. +
+ ) + } + + if (!scene) { + return ( +
+ The selected scene is not available. +
+ ) + } + + const fields = (scene.fields ?? []).filter((field) => field.access === 'public') + const state = leaf.state ?? {} + + return ( +
+
+
+
Scene options
+
{scene.name || 'Untitled scene'}
+
+ {Object.keys(state).length > 0 ? ( + {Object.keys(state).length} changed + ) : null} +
+ + {fields.length === 0 ? ( +
This scene does not expose public options.
+ ) : ( +
+ {fields.map((field) => { + const changed = Object.prototype.hasOwnProperty.call(state, field.name) + return ( +
+ +
+ onSetSceneStateValue(leaf.id, field, value)} + currentState={{}} + stateChanges={state} + /> +
+
+ ) + })} +
+ )} +
+ ) +} + +function SplitPreviewDivider({ + divider, + previewRef, + onStartResize, +}: { + divider: SplitLayoutDivider + previewRef: RefObject + onStartResize: (divider: SplitLayoutDivider, rect: DOMRect, event: PointerEvent) => void +}): JSX.Element { + const vertical = divider.orientation === 'vertical' + + return ( + + + + + +
+
+
click multiple times to rotate
+
+ {visiblePresets.map((preset) => ( + + ))} + {!morePresetsOpen && } +
+ + + +
+ {layout.background.sceneId ? ( +
+ +
+ ) : null} + {rects.map((rect) => ( + assignSceneWithTitle(leafId, null)} + onSelect={selectPreviewLeaf} + /> + ))} + {dividers.map((divider) => ( + + ))} +
+ + +
+ +
+ +
+
+ + ) +} diff --git a/frontend/src/scenes/workspace/splitScreenLayoutLogic.ts b/frontend/src/scenes/workspace/splitScreenLayoutLogic.ts new file mode 100644 index 000000000..a3789b348 --- /dev/null +++ b/frontend/src/scenes/workspace/splitScreenLayoutLogic.ts @@ -0,0 +1,240 @@ +import { actions, beforeUnmount, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import type { splitScreenLayoutLogicType } from './splitScreenLayoutLogicType' +import { + assignSceneToSplitLayoutLeaf, + cloneSplitLayoutNode, + cloneSplitScreenSceneLayout, + defaultSplitScreenBackground, + configuredSplitLayoutLeafCount, + defaultSplitScreenSceneLayout, + rotateSplitLayoutNode, + splitLayoutPresetById, + splitScreenLayoutPresets, + setSplitLayoutLeafStateValue, + updateSplitLayoutAdjacentRatio, + type SplitLayoutBranch, + type SplitScreenSceneLayout, +} from '../../utils/splitScreenLayouts' +import type { StateField } from '../../types' + +export interface SplitScreenLayoutLogicProps { + frameId: number +} + +export interface SplitScreenResizeState { + parentId: string + index: number + orientation: 'vertical' | 'horizontal' + parentStartPx: number + parentSizePx: number +} + +function defaultPresetId(): string { + return splitScreenLayoutPresets[0].id +} + +function clampBorderWidth(borderWidth: number): number { + return Math.max(0, Math.min(48, Math.round(Number(borderWidth) || 0))) +} + +function clampOpacity(opacity: number): number { + return Math.max(0, Math.min(1, Number(opacity) || 0)) +} + +function layoutForPreset(presetId: string, previous?: SplitScreenSceneLayout | null): SplitScreenSceneLayout { + return { + name: previous?.name ?? 'Split screen', + borderWidth: previous?.borderWidth ?? 0, + outerBorderWidth: previous?.outerBorderWidth ?? 0, + background: previous?.background ?? { ...defaultSplitScreenBackground }, + root: cloneSplitLayoutNode(splitLayoutPresetById(presetId).root), + } +} + +function removeResizeListeners(cache: Record): void { + if (typeof window === 'undefined') { + return + } + if (cache.pointerMoveListener) { + window.removeEventListener('pointermove', cache.pointerMoveListener) + cache.pointerMoveListener = null + } + if (cache.pointerUpListener) { + window.removeEventListener('pointerup', cache.pointerUpListener) + window.removeEventListener('pointercancel', cache.pointerUpListener) + cache.pointerUpListener = null + } +} + +export const splitScreenLayoutLogic = kea([ + path(['src', 'scenes', 'workspace', 'splitScreenLayoutLogic']), + props({} as SplitScreenLayoutLogicProps), + key((props) => props.frameId), + actions({ + openGenerator: (sceneId?: string | null, layout?: SplitScreenSceneLayout | null) => ({ + layout: layout ?? null, + sceneId: sceneId ?? null, + }), + closeGenerator: true, + showMorePresets: true, + selectPreset: (presetId: string, rotate?: boolean) => ({ presetId, rotate: Boolean(rotate) }), + selectLeaf: (leafId: string | null) => ({ leafId }), + setLayoutName: (name: string) => ({ name }), + setBorderWidth: (borderWidth: number) => ({ borderWidth }), + setOuterBorderWidth: (outerBorderWidth: number) => ({ outerBorderWidth }), + setBackgroundColor: (color: string) => ({ color }), + setBackgroundScene: (sceneId: string | null) => ({ sceneId }), + setBackgroundOpacity: (opacity: number) => ({ opacity }), + setSceneSearch: (search: string) => ({ search }), + setLeafSceneStateValue: (leafId: string, field: StateField, value: any) => ({ field, leafId, value }), + assignSceneToLeaf: (leafId: string, sceneId: string | null) => ({ leafId, sceneId }), + startResize: ( + parentId: string, + index: number, + orientation: 'vertical' | 'horizontal', + parentStartPx: number, + parentSizePx: number + ) => ({ + index, + orientation, + parentId, + parentSizePx, + parentStartPx, + }), + updateResize: (clientX: number, clientY: number) => ({ clientX, clientY }), + resizeLayout: (parentId: string, index: number, positionRatio: number) => ({ index, parentId, positionRatio }), + finishResize: true, + }), + reducers({ + generatorOpen: [ + false, + { + openGenerator: () => true, + closeGenerator: () => false, + }, + ], + editingSceneId: [ + null as string | null, + { + openGenerator: (_, { sceneId }) => sceneId, + closeGenerator: () => null, + }, + ], + selectedPresetId: [ + defaultPresetId() as string | null, + { + openGenerator: (_, { layout }) => (layout ? null : defaultPresetId()), + selectPreset: (_, { presetId }) => presetId, + }, + ], + morePresetsOpen: [ + false, + { + openGenerator: () => false, + showMorePresets: () => true, + }, + ], + selectedLeafId: [ + null as string | null, + { + openGenerator: () => null, + closeGenerator: () => null, + selectPreset: () => null, + selectLeaf: (_, { leafId }) => leafId, + }, + ], + sceneSearch: [ + '', + { + openGenerator: () => '', + closeGenerator: () => '', + setSceneSearch: (_, { search }) => search, + }, + ], + layout: [ + defaultSplitScreenSceneLayout(), + { + openGenerator: (_, { layout }) => + layout ? cloneSplitScreenSceneLayout(layout) : layoutForPreset(defaultPresetId()), + selectPreset: (state, { presetId, rotate }) => + rotate ? { ...state, root: rotateSplitLayoutNode(state.root) } : layoutForPreset(presetId, state), + setLayoutName: (state, { name }) => ({ + ...state, + name, + }), + setBorderWidth: (state, { borderWidth }) => ({ + ...state, + borderWidth: clampBorderWidth(borderWidth), + }), + setOuterBorderWidth: (state, { outerBorderWidth }) => ({ + ...state, + outerBorderWidth: clampBorderWidth(outerBorderWidth), + }), + setBackgroundColor: (state, { color }) => ({ + ...state, + background: { ...state.background, color }, + }), + setBackgroundScene: (state, { sceneId }) => ({ + ...state, + background: { ...state.background, sceneId }, + }), + setBackgroundOpacity: (state, { opacity }) => ({ + ...state, + background: { ...state.background, opacity: clampOpacity(opacity) }, + }), + assignSceneToLeaf: (state, { leafId, sceneId }) => ({ + ...state, + root: assignSceneToSplitLayoutLeaf(state.root, leafId, sceneId), + }), + setLeafSceneStateValue: (state, { leafId, field, value }) => ({ + ...state, + root: setSplitLayoutLeafStateValue(state.root, leafId, field, value), + }), + resizeLayout: (state, { parentId, index, positionRatio }) => ({ + ...state, + root: updateSplitLayoutAdjacentRatio(state.root, parentId, index, positionRatio), + }), + }, + ], + resizing: [ + null as SplitScreenResizeState | null, + { + startResize: (_, payload) => payload, + finishResize: () => null, + closeGenerator: () => null, + }, + ], + }), + selectors({ + root: [(s) => [s.layout], (layout): SplitLayoutBranch => layout.root], + configuredLeafCount: [(s) => [s.root], (root): number => configuredSplitLayoutLeafCount(root)], + }), + listeners(({ actions, cache, values }) => ({ + startResize: () => { + if (typeof window === 'undefined') { + return + } + removeResizeListeners(cache) + cache.pointerMoveListener = (event: PointerEvent) => { + event.preventDefault() + actions.updateResize(event.clientX, event.clientY) + } + cache.pointerUpListener = () => actions.finishResize() + window.addEventListener('pointermove', cache.pointerMoveListener) + window.addEventListener('pointerup', cache.pointerUpListener) + window.addEventListener('pointercancel', cache.pointerUpListener) + }, + updateResize: ({ clientX, clientY }) => { + const resizing = values.resizing + if (!resizing || resizing.parentSizePx <= 0) { + return + } + const coordinate = resizing.orientation === 'vertical' ? clientX : clientY + const positionRatio = (coordinate - resizing.parentStartPx) / resizing.parentSizePx + actions.resizeLayout(resizing.parentId, resizing.index, positionRatio) + }, + finishResize: () => removeResizeListeners(cache), + closeGenerator: () => removeResizeListeners(cache), + })), + beforeUnmount(({ cache }) => removeResizeListeners(cache)), +]) diff --git a/frontend/src/scenes/workspace/workspaceLogic.tsx b/frontend/src/scenes/workspace/workspaceLogic.tsx index f1b2d7c6b..788adff31 100644 --- a/frontend/src/scenes/workspace/workspaceLogic.tsx +++ b/frontend/src/scenes/workspace/workspaceLogic.tsx @@ -1275,10 +1275,10 @@ export const workspaceLogic = kea([ installMethod === 'sd_card' ? 'sdCard' : installMethod === 'script' - ? 'script' - : installMethod === 'embedded' - ? 'embedded' - : 'main' + ? 'script' + : installMethod === 'embedded' + ? 'embedded' + : 'main' actions.setSearch('') actions.selectFrame(frameId) cache.skipNextFrameChangeDrawerScrollPreserve = true @@ -1567,10 +1567,16 @@ export const workspaceLogic = kea([ }, })), afterMount(({ actions, cache, values }) => { + cache.scrollGuardUnmounted = false applyFrameosTheme(values.theme) if (typeof window !== 'undefined') { const mobileWorkspaceQuery = window.matchMedia?.(MOBILE_WORKSPACE_MEDIA_QUERY) - const syncScrollGuardForViewport = () => applyWorkspaceScrollGuard(workspaceLogic.values.secondarySidebarOpen) + const syncScrollGuardForViewport = () => { + if (cache.scrollGuardUnmounted) { + return + } + applyWorkspaceScrollGuard(values.secondarySidebarOpen) + } if (mobileWorkspaceQuery) { cache.mobileWorkspaceQuery = mobileWorkspaceQuery cache.syncScrollGuardForViewport = syncScrollGuardForViewport @@ -1593,6 +1599,7 @@ export const workspaceLogic = kea([ }), events(({ cache }) => ({ beforeUnmount: () => { + cache.scrollGuardUnmounted = true if (cache.mobileWorkspaceQuery?.removeEventListener) { cache.mobileWorkspaceQuery.removeEventListener('change', cache.syncScrollGuardForViewport) } else { diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index 0ae56fbf8..b17453d6b 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -559,6 +559,7 @@ export interface FrameSceneSettings { execution?: 'compiled' | 'interpreted' prompt?: string autoArrangeOnLoad?: boolean + splitScreenLayout?: Record } export interface FrameScene { diff --git a/frontend/src/utils/splitScreenLayouts.ts b/frontend/src/utils/splitScreenLayouts.ts new file mode 100644 index 000000000..f5fe4f0c1 --- /dev/null +++ b/frontend/src/utils/splitScreenLayouts.ts @@ -0,0 +1,611 @@ +import type { StateField } from '../types' + +export type SplitLayoutDirection = 'row' | 'column' + +export interface SplitLayoutLeaf { + id: string + type: 'leaf' + sceneId?: string | null + state?: Record +} + +export interface SplitLayoutBranch { + id: string + type: 'split' + direction: SplitLayoutDirection + ratios: number[] + children: SplitLayoutNode[] +} + +export type SplitLayoutNode = SplitLayoutLeaf | SplitLayoutBranch + +export interface SplitScreenBackground { + color: string + sceneId?: string | null + opacity: number +} + +export interface SplitScreenSceneLayout { + name: string + borderWidth: number + outerBorderWidth: number + background: SplitScreenBackground + root: SplitLayoutBranch +} + +export interface SplitLayoutPreset { + id: string + name: string + root: SplitLayoutBranch +} + +export interface SplitLayoutLeafRect { + leafId: string + sceneId?: string | null + x: number + y: number + width: number + height: number +} + +export interface SplitLayoutDivider { + parentId: string + index: number + orientation: 'vertical' | 'horizontal' + x: number + y: number + width: number + height: number + parentX: number + parentY: number + parentWidth: number + parentHeight: number +} + +export interface SplitLayoutLeafBorderEdges { + top: boolean + right: boolean + bottom: boolean + left: boolean +} + +const MIN_RATIO = 0.15 +const EDGE_EPSILON = 0.001 + +export const defaultSplitScreenBackground: SplitScreenBackground = { + color: '#f8fafc', + sceneId: null, + opacity: 1, +} + +function leaf(id: string): SplitLayoutLeaf { + return { id, type: 'leaf', sceneId: null, state: {} } +} + +function split( + id: string, + direction: SplitLayoutDirection, + ratios: number[], + children: SplitLayoutNode[] +): SplitLayoutBranch { + return { id, type: 'split', direction, ratios, children } +} + +export const splitScreenLayoutPresets: SplitLayoutPreset[] = [ + { + id: 'two-columns', + name: '2', + root: split('two-columns/root', 'row', [1, 1], [leaf('two-columns/a'), leaf('two-columns/b')]), + }, + { + id: 'three-columns', + name: '3', + root: split( + 'three-columns/root', + 'row', + [1, 1, 1], + [leaf('three-columns/a'), leaf('three-columns/b'), leaf('three-columns/c')] + ), + }, + { + id: 'left-plus-two', + name: '1 + 2', + root: split( + 'left-plus-two/root', + 'row', + [1.45, 1], + [ + leaf('left-plus-two/a'), + split('left-plus-two/right', 'column', [1, 1], [leaf('left-plus-two/b'), leaf('left-plus-two/c')]), + ] + ), + }, + { + id: 'grid-four', + name: '2 x 2', + root: split( + 'grid-four/root', + 'column', + [1, 1], + [ + split('grid-four/top', 'row', [1, 1], [leaf('grid-four/a'), leaf('grid-four/b')]), + split('grid-four/bottom', 'row', [1, 1], [leaf('grid-four/c'), leaf('grid-four/d')]), + ] + ), + }, + { + id: 'three-plus-two', + name: '3 + 2', + root: split( + 'three-plus-two/root', + 'column', + [1, 1], + [ + split( + 'three-plus-two/top', + 'row', + [1, 1, 1], + [leaf('three-plus-two/a'), leaf('three-plus-two/b'), leaf('three-plus-two/c')] + ), + split('three-plus-two/bottom', 'row', [1, 1], [leaf('three-plus-two/d'), leaf('three-plus-two/e')]), + ] + ), + }, + { + id: 'four-columns', + name: '4', + root: split( + 'four-columns/root', + 'row', + [1, 1, 1, 1], + [leaf('four-columns/a'), leaf('four-columns/b'), leaf('four-columns/c'), leaf('four-columns/d')] + ), + }, + { + id: 'one-plus-three', + name: '1 + 3', + root: split( + 'one-plus-three/root', + 'row', + [1.55, 1], + [ + leaf('one-plus-three/a'), + split( + 'one-plus-three/right', + 'column', + [1, 1, 1], + [leaf('one-plus-three/b'), leaf('one-plus-three/c'), leaf('one-plus-three/d')] + ), + ] + ), + }, + { + id: 'one-plus-four', + name: '1 + 4', + root: split( + 'one-plus-four/root', + 'row', + [1.55, 1], + [ + leaf('one-plus-four/a'), + split( + 'one-plus-four/right', + 'column', + [1, 1], + [ + split('one-plus-four/right-top', 'row', [1, 1], [leaf('one-plus-four/b'), leaf('one-plus-four/c')]), + split('one-plus-four/right-bottom', 'row', [1, 1], [leaf('one-plus-four/d'), leaf('one-plus-four/e')]), + ] + ), + ] + ), + }, +] + +export function cloneSplitLayoutNode(node: T): T { + return JSON.parse(JSON.stringify(node)) as T +} + +export function rotateSplitLayoutNode(node: T): T { + if (node.type === 'leaf') { + return { ...node } as T + } + + const rotatedChildren = node.children.map((child) => rotateSplitLayoutNode(child)) + const rotatedRatios = [...node.ratios] + const reverseOrder = node.direction === 'column' + + return { + ...node, + direction: node.direction === 'row' ? 'column' : 'row', + ratios: reverseOrder ? rotatedRatios.reverse() : rotatedRatios, + children: reverseOrder ? rotatedChildren.reverse() : rotatedChildren, + } as T +} + +export function cloneSplitScreenSceneLayout(layout: SplitScreenSceneLayout): SplitScreenSceneLayout { + const outerBorderWidth = layout.outerBorderWidth ?? ((layout as any).outerBorder ? layout.borderWidth : 0) + return { + name: layout.name || 'Split screen', + borderWidth: Math.max(0, Math.min(48, Math.round(Number(layout.borderWidth) || 0))), + outerBorderWidth: Math.max(0, Math.min(48, Math.round(Number(outerBorderWidth) || 0))), + background: { + color: layout.background?.color || defaultSplitScreenBackground.color, + sceneId: layout.background?.sceneId || null, + opacity: Math.max( + 0, + Math.min(1, Number(layout.background?.opacity ?? defaultSplitScreenBackground.opacity) || 0) + ), + }, + root: cloneSplitLayoutNode(layout.root), + } +} + +function normalizedLeafState(value: any): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return Object.fromEntries(Object.entries(value).filter(([key]) => key)) +} + +function normalizeSplitLayoutNode(value: any): SplitLayoutNode | null { + if (!value || typeof value !== 'object' || typeof value.id !== 'string') { + return null + } + + if (value.type === 'leaf') { + return { + id: value.id, + type: 'leaf', + sceneId: typeof value.sceneId === 'string' ? value.sceneId : null, + state: normalizedLeafState(value.state), + } + } + + if (value.type !== 'split' || (value.direction !== 'row' && value.direction !== 'column')) { + return null + } + + const sourceChildren: any[] = Array.isArray(value.children) ? value.children : [] + const children = sourceChildren + .map(normalizeSplitLayoutNode) + .filter((child: SplitLayoutNode | null): child is SplitLayoutNode => Boolean(child)) + if (children.length === 0) { + return null + } + + const sourceRatios: any[] = Array.isArray(value.ratios) ? value.ratios : [] + const ratios = children.map((_child: SplitLayoutNode, index: number) => { + const ratio = Number(sourceRatios[index]) + return Number.isFinite(ratio) && ratio > 0 ? ratio : 1 + }) + + return { + id: value.id, + type: 'split', + direction: value.direction, + ratios, + children, + } +} + +export function normalizeSplitScreenSceneLayout(value: any): SplitScreenSceneLayout | null { + if (!value || typeof value !== 'object') { + return null + } + + const root = normalizeSplitLayoutNode(value.root) + if (!root || root.type !== 'split') { + return null + } + + return { + name: typeof value.name === 'string' && value.name.trim() ? value.name : 'Split screen', + borderWidth: Math.max(0, Math.min(48, Math.round(Number(value.borderWidth) || 0))), + outerBorderWidth: Math.max( + 0, + Math.min(48, Math.round(Number(value.outerBorderWidth ?? (value.outerBorder ? value.borderWidth : 0)) || 0)) + ), + background: { + color: typeof value.background?.color === 'string' ? value.background.color : defaultSplitScreenBackground.color, + sceneId: typeof value.background?.sceneId === 'string' ? value.background.sceneId : null, + opacity: Math.max(0, Math.min(1, Number(value.background?.opacity ?? defaultSplitScreenBackground.opacity) || 0)), + }, + root, + } +} + +export function defaultSplitScreenSceneLayout(): SplitScreenSceneLayout { + return { + name: 'Split screen', + borderWidth: 0, + outerBorderWidth: 0, + background: { ...defaultSplitScreenBackground }, + root: cloneSplitLayoutNode(splitScreenLayoutPresets[0].root), + } +} + +export function splitLayoutPresetById(presetId: string): SplitLayoutPreset { + return splitScreenLayoutPresets.find((preset) => preset.id === presetId) ?? splitScreenLayoutPresets[0] +} + +function normalizeRatios(ratios: number[], expectedLength: number): number[] { + const normalized = Array.from({ length: expectedLength }, (_, index) => { + const ratio = Number(ratios[index]) + return Number.isFinite(ratio) && ratio > 0 ? ratio : 1 + }) + return normalized.every((ratio) => ratio <= 0) ? normalized.map(() => 1) : normalized +} + +function mapSplitLayoutNode( + node: SplitLayoutNode, + mapper: (node: SplitLayoutNode) => SplitLayoutNode +): SplitLayoutNode { + const mapped = mapper(node) + if (mapped.type === 'leaf') { + return mapped + } + return { + ...mapped, + ratios: normalizeRatios(mapped.ratios, mapped.children.length), + children: mapped.children.map((child) => mapSplitLayoutNode(child, mapper)), + } +} + +export function assignSceneToSplitLayoutLeaf( + root: SplitLayoutBranch, + leafId: string, + sceneId: string | null +): SplitLayoutBranch { + return mapSplitLayoutNode(root, (node) => { + if (node.type !== 'leaf' || node.id !== leafId) { + return node + } + return { + ...node, + sceneId, + state: node.sceneId === sceneId ? node.state ?? {} : {}, + } + }) as SplitLayoutBranch +} + +function normalizeSceneStateValue(field: StateField, value: any): any { + if (value === null || value === undefined) { + return undefined + } + if (field.type === 'boolean') { + if (value === '') { + return undefined + } + return value === true || value === 'true' + } + if (field.type === 'integer') { + const parsed = parseInt(String(value), 10) + return Number.isFinite(parsed) ? parsed : undefined + } + if (field.type === 'float') { + const parsed = parseFloat(String(value)) + return Number.isFinite(parsed) ? parsed : undefined + } + if (typeof value === 'string') { + return value + } + return value +} + +function stateValuesMatch(first: any, second: any): boolean { + if ((first === '' && second === undefined) || (first === undefined && second === '')) { + return true + } + return JSON.stringify(first ?? null) === JSON.stringify(second ?? null) +} + +export function setSplitLayoutLeafStateValue( + root: SplitLayoutBranch, + leafId: string, + field: StateField, + value: any +): SplitLayoutBranch { + return mapSplitLayoutNode(root, (node) => { + if (node.type !== 'leaf' || node.id !== leafId) { + return node + } + const nextState = { ...(node.state ?? {}) } + const normalizedValue = normalizeSceneStateValue(field, value) + const normalizedDefault = normalizeSceneStateValue(field, field.value) + if (normalizedValue === undefined || stateValuesMatch(normalizedValue, normalizedDefault)) { + delete nextState[field.name] + } else { + nextState[field.name] = normalizedValue + } + return { ...node, state: nextState } + }) as SplitLayoutBranch +} + +export function updateSplitLayoutAdjacentRatio( + root: SplitLayoutBranch, + parentId: string, + index: number, + positionRatio: number +): SplitLayoutBranch { + return mapSplitLayoutNode(root, (node) => { + if (node.type !== 'split' || node.id !== parentId || index < 0 || index >= node.children.length - 1) { + return node + } + + const ratios = normalizeRatios(node.ratios, node.children.length) + const total = ratios.reduce((sum, ratio) => sum + ratio, 0) + const before = ratios.slice(0, index).reduce((sum, ratio) => sum + ratio, 0) + const pair = ratios[index] + ratios[index + 1] + const position = Math.max(0, Math.min(1, positionRatio)) * total + const minPairRatio = Math.min(pair / 2, Math.max(MIN_RATIO, pair * 0.08)) + const nextFirst = Math.max(minPairRatio, Math.min(pair - minPairRatio, position - before)) + const nextRatios = [...ratios] + nextRatios[index] = nextFirst + nextRatios[index + 1] = pair - nextFirst + return { ...node, ratios: nextRatios } + }) as SplitLayoutBranch +} + +export function splitLayoutLeaves(node: SplitLayoutNode): SplitLayoutLeaf[] { + if (node.type === 'leaf') { + return [node] + } + return node.children.flatMap(splitLayoutLeaves) +} + +export function configuredSplitLayoutLeafCount(node: SplitLayoutNode): number { + return splitLayoutLeaves(node).filter((leaf) => Boolean(leaf.sceneId)).length +} + +export function splitLayoutLeafRects(root: SplitLayoutNode): SplitLayoutLeafRect[] { + const rects: SplitLayoutLeafRect[] = [] + + const visit = (node: SplitLayoutNode, x: number, y: number, width: number, height: number): void => { + if (node.type === 'leaf') { + rects.push({ leafId: node.id, sceneId: node.sceneId, x, y, width, height }) + return + } + + const ratios = normalizeRatios(node.ratios, node.children.length) + const total = ratios.reduce((sum, ratio) => sum + ratio, 0) || 1 + let offset = 0 + + node.children.forEach((child, index) => { + const share = ratios[index] / total + if (node.direction === 'row') { + const childWidth = width * share + visit(child, x + offset, y, childWidth, height) + offset += childWidth + } else { + const childHeight = height * share + visit(child, x, y + offset, width, childHeight) + offset += childHeight + } + }) + } + + visit(root, 0, 0, 100, 100) + return rects +} + +function rangesOverlap(startA: number, endA: number, startB: number, endB: number): boolean { + return Math.min(endA, endB) - Math.max(startA, startB) > EDGE_EPSILON +} + +function closeEnough(a: number, b: number): boolean { + return Math.abs(a - b) <= EDGE_EPSILON +} + +export function splitLayoutLeafBorderEdges(rects: SplitLayoutLeafRect[]): Map { + const edges = new Map() + + for (const rect of rects) { + const rectRight = rect.x + rect.width + const rectBottom = rect.y + rect.height + const nextEdges: SplitLayoutLeafBorderEdges = { + top: false, + right: false, + bottom: false, + left: false, + } + + for (const other of rects) { + if (other.leafId === rect.leafId) { + continue + } + const otherRight = other.x + other.width + const otherBottom = other.y + other.height + const verticalOverlap = rangesOverlap(rect.y, rectBottom, other.y, otherBottom) + const horizontalOverlap = rangesOverlap(rect.x, rectRight, other.x, otherRight) + + if (verticalOverlap && closeEnough(rect.x, otherRight)) { + nextEdges.left = true + } + if (verticalOverlap && closeEnough(rectRight, other.x)) { + nextEdges.right = true + } + if (horizontalOverlap && closeEnough(rect.y, otherBottom)) { + nextEdges.top = true + } + if (horizontalOverlap && closeEnough(rectBottom, other.y)) { + nextEdges.bottom = true + } + } + + edges.set(rect.leafId, nextEdges) + } + + return edges +} + +export function splitLayoutOuterBorderEdges(rect: SplitLayoutLeafRect): SplitLayoutLeafBorderEdges { + const right = rect.x + rect.width + const bottom = rect.y + rect.height + return { + top: closeEnough(rect.y, 0), + right: closeEnough(right, 100), + bottom: closeEnough(bottom, 100), + left: closeEnough(rect.x, 0), + } +} + +export function splitLayoutDividers(root: SplitLayoutNode): SplitLayoutDivider[] { + const dividers: SplitLayoutDivider[] = [] + + const visit = (node: SplitLayoutNode, x: number, y: number, width: number, height: number): void => { + if (node.type === 'leaf') { + return + } + + const ratios = normalizeRatios(node.ratios, node.children.length) + const total = ratios.reduce((sum, ratio) => sum + ratio, 0) || 1 + let offset = 0 + + node.children.forEach((child, index) => { + const share = ratios[index] / total + if (node.direction === 'row') { + const childWidth = width * share + visit(child, x + offset, y, childWidth, height) + offset += childWidth + if (index < node.children.length - 1) { + dividers.push({ + parentId: node.id, + index, + orientation: 'vertical', + x: x + offset, + y, + width: 0, + height, + parentX: x, + parentY: y, + parentWidth: width, + parentHeight: height, + }) + } + } else { + const childHeight = height * share + visit(child, x, y + offset, width, childHeight) + offset += childHeight + if (index < node.children.length - 1) { + dividers.push({ + parentId: node.id, + index, + orientation: 'horizontal', + x, + y: y + offset, + width, + height: 0, + parentX: x, + parentY: y, + parentWidth: width, + parentHeight: height, + }) + } + } + }) + } + + visit(root, 0, 0, 100, 100) + return dividers +} diff --git a/frontend/src/utils/splitScreenThumbnail.ts b/frontend/src/utils/splitScreenThumbnail.ts new file mode 100644 index 000000000..6b0618468 --- /dev/null +++ b/frontend/src/utils/splitScreenThumbnail.ts @@ -0,0 +1,178 @@ +import type { FrameType } from '../types' +import { getBasePath } from './getBasePath' +import { projectApiPath } from './projectApi' +import { + defaultSplitScreenBackground, + splitLayoutLeafBorderEdges, + splitLayoutLeafRects, + splitLayoutOuterBorderEdges, + type SplitScreenBackground, + type SplitScreenSceneLayout, +} from './splitScreenLayouts' + +function frameDisplayDimensions(frame: Pick): { + width: number + height: number +} { + const width = frame.width && frame.width > 0 ? frame.width : 800 + const height = frame.height && frame.height > 0 ? frame.height : 480 + return frame.rotate === 90 || frame.rotate === 270 ? { width: height, height: width } : { width, height } +} + +function fitCanvasDimensions(width: number, height: number): { width: number; height: number } { + const maxSide = 720 + const scale = Math.min(1, maxSide / Math.max(width, height)) + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +function imageApiUrl(path: string): string { + const basePath = getBasePath() + return path.startsWith('/api/') && basePath ? `${basePath}${path}` : path +} + +async function fetchSceneImage(frameId: number, sceneId: string): Promise { + const apiPath = await projectApiPath(`/api/frames/${frameId}/scene_images/${sceneId}`) + const imageUrl = `${imageApiUrl(apiPath)}?thumb=1&t=${Date.now()}` + const response = await fetch(imageUrl, { credentials: 'include' }) + if (!response.ok) { + return null + } + + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const image = new Image() + return await new Promise((resolve) => { + image.onload = () => resolve(image) + image.onerror = () => resolve(null) + image.src = objectUrl + }).finally(() => { + URL.revokeObjectURL(objectUrl) + }) +} + +function drawCover( + context: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number +): void { + const sourceRatio = image.naturalWidth / image.naturalHeight + const targetRatio = width / height + let sourceX = 0 + let sourceY = 0 + let sourceWidth = image.naturalWidth + let sourceHeight = image.naturalHeight + + if (sourceRatio > targetRatio) { + sourceWidth = image.naturalHeight * targetRatio + sourceX = (image.naturalWidth - sourceWidth) / 2 + } else { + sourceHeight = image.naturalWidth / targetRatio + sourceY = (image.naturalHeight - sourceHeight) / 2 + } + + context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height) +} + +function canvasToBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png')) +} + +function splitBackground(layout: SplitScreenSceneLayout): SplitScreenBackground { + return { + ...defaultSplitScreenBackground, + ...(layout.background ?? {}), + opacity: Math.max(0, Math.min(1, Number(layout.background?.opacity ?? defaultSplitScreenBackground.opacity) || 0)), + } +} + +export async function buildSplitScreenThumbnail( + frame: Pick, + layout: SplitScreenSceneLayout +): Promise { + if (typeof document === 'undefined') { + return null + } + + const dimensions = frameDisplayDimensions(frame) + const canvasDimensions = fitCanvasDimensions(dimensions.width, dimensions.height) + const canvas = document.createElement('canvas') + canvas.width = canvasDimensions.width + canvas.height = canvasDimensions.height + + const context = canvas.getContext('2d') + if (!context) { + return null + } + + const background = splitBackground(layout) + context.fillStyle = background.color + context.fillRect(0, 0, canvas.width, canvas.height) + + let backgroundImage: HTMLImageElement | null = null + if (background.sceneId) { + backgroundImage = await fetchSceneImage(frame.id, background.sceneId) + if (backgroundImage) { + context.save() + context.globalAlpha = background.opacity + drawCover(context, backgroundImage, 0, 0, canvas.width, canvas.height) + context.restore() + } + } + + const rects = splitLayoutLeafRects(layout.root) + const borderEdges = splitLayoutLeafBorderEdges(rects) + const sceneIds = Array.from(new Set(rects.map((rect) => rect.sceneId).filter(Boolean))) as string[] + const images = new Map() + if (background.sceneId && backgroundImage) { + images.set(background.sceneId, backgroundImage) + } + await Promise.all( + sceneIds + .filter((sceneId) => !images.has(sceneId)) + .map(async (sceneId) => images.set(sceneId, await fetchSceneImage(frame.id, sceneId))) + ) + + const scale = canvas.width / dimensions.width + const borderWidth = Math.max(0, Math.round((Number(layout.borderWidth) || 0) * scale)) + const halfBorderWidth = borderWidth / 2 + const rawOuterBorderWidth = layout.outerBorderWidth ?? ((layout as any).outerBorder ? layout.borderWidth : 0) + const outerBorderWidth = Math.max(0, Math.round((Number(rawOuterBorderWidth) || 0) * scale)) + + for (const rect of rects) { + const x = Math.round((rect.x / 100) * canvas.width) + const y = Math.round((rect.y / 100) * canvas.height) + const width = Math.max(1, Math.round((rect.width / 100) * canvas.width)) + const height = Math.max(1, Math.round((rect.height / 100) * canvas.height)) + const edges = borderEdges.get(rect.leafId) ?? { top: false, right: false, bottom: false, left: false } + const outerEdges = outerBorderWidth > 0 ? splitLayoutOuterBorderEdges(rect) : null + const insetTop = edges.top ? halfBorderWidth : outerEdges?.top ? outerBorderWidth : 0 + const insetRight = edges.right ? halfBorderWidth : outerEdges?.right ? outerBorderWidth : 0 + const insetBottom = edges.bottom ? halfBorderWidth : outerEdges?.bottom ? outerBorderWidth : 0 + const insetLeft = edges.left ? halfBorderWidth : outerEdges?.left ? outerBorderWidth : 0 + const cellX = Math.round(x + insetLeft) + const cellY = Math.round(y + insetTop) + const cellWidth = Math.max(1, Math.round(width - insetLeft - insetRight)) + const cellHeight = Math.max(1, Math.round(height - insetTop - insetBottom)) + const image = rect.sceneId ? images.get(rect.sceneId) : null + + context.save() + context.beginPath() + context.rect(cellX, cellY, cellWidth, cellHeight) + context.clip() + if (image) { + drawCover(context, image, cellX, cellY, cellWidth, cellHeight) + } else { + context.fillStyle = '#e2e8f0' + context.fillRect(cellX, cellY, cellWidth, cellHeight) + } + context.restore() + } + + return await canvasToBlob(canvas) +} diff --git a/package.json b/package.json index fb0b6bd04..2c66c3a18 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,13 @@ "@types/react": "18.0.19", "@types/react-dom": "18.0.6", "csstype": "3.2.3" + }, + "packageExtensions": { + "kea-forms@*": { + "peerDependencies": { + "typescript": ">=5.0.0" + } + } } }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34a2ead86..fe0c842d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,8 @@ overrides: '@types/react-dom': 18.0.6 csstype: 3.2.3 +packageExtensionsChecksum: sha256-vsSjLdIOYVSo9NEFqxgkMNt3v3VTzjXkaTYuz7XdnIM= + importers: .: @@ -120,7 +122,7 @@ importers: version: 3.1.7(react@18.2.0) kea-forms: specifier: ^3.2.0 - version: 3.2.0(kea@3.1.7(react@18.2.0)) + version: 3.2.0(kea@3.1.7(react@18.2.0))(typescript@5.8.3) kea-loaders: specifier: 3.1.1 version: 3.1.1(kea@3.1.7(react@18.2.0)) @@ -307,7 +309,7 @@ importers: version: 3.1.7(react@18.2.0) kea-forms: specifier: ^3.2.0 - version: 3.2.0(kea@3.1.7(react@18.2.0)) + version: 3.2.0(kea@3.1.7(react@18.2.0))(typescript@5.9.3) kea-loaders: specifier: ^3.1.1 version: 3.1.1(kea@3.1.7(react@18.2.0)) @@ -412,8 +414,8 @@ importers: specifier: ^0.2.2 version: 0.2.4(kea@3.1.7(react@18.2.0)) kea-typegen: - specifier: 3.3.4 - version: 3.3.4(typescript@5.8.3) + specifier: 3.7.1 + version: 3.7.1(typescript@5.9.3) postcss: specifier: ^8.4.27 version: 8.5.8 @@ -436,8 +438,8 @@ importers: specifier: ^1.3.0 version: 1.5.1 typescript: - specifier: 5.8.3 - version: 5.8.3 + specifier: 5.9.3 + version: 5.9.3 vite: specifier: ^7.3.1 version: 7.3.1(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3) @@ -2983,6 +2985,7 @@ packages: resolution: {integrity: sha512-R/ECGx6FxOZ2TEJv2GcFNLcQJePUK5Qd4Km81rN/7lBHd2hG4CAJglODVpcolWZ0RtcLcvMhHeTg/iqNz5pynw==} peerDependencies: kea: '>= 3.0.1' + typescript: '>=5.0.0' kea-loaders@3.1.1: resolution: {integrity: sha512-FZcOh5PSTIh5+Of5kZno7bXENmY4rCcRYnTB4e709Mbkc7KzriEZ6b/mIWvYx2vkDEgilIsvibUsGOADZ6wqPw==} @@ -3009,11 +3012,11 @@ packages: peerDependencies: kea: '>= 3' - kea-typegen@3.3.4: - resolution: {integrity: sha512-5bj9EOq0q0JZC5Krkdt5uf1MqBTYHKL+FbbRbSRUey3BMaLMlaSxD7GYL532BEgwvQ/zMTb0nTJ1EY8t4sreDw==} + kea-typegen@3.7.1: + resolution: {integrity: sha512-hQuV38I9OyCui/0HviWsgblabeN6iE8t2sOCF8cmoAd1Zk5nR3/5kkaepQGvkaqgwBjy8XcWzVgkCimE8jExoQ==} hasBin: true peerDependencies: - typescript: '>=4.9.5' + typescript: '>=5.9.0' kea-waitfor@0.2.1: resolution: {integrity: sha512-oXZ42wZl46+KShuPORgAHKFQr1ewrNJ1ZVBUFLDyn4J3XTndQqCvN0GaFrSCIR0qeBzGHtNu/WwPgVGsmDH8DA==} @@ -3741,6 +3744,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + prettier@3.8.5: + resolution: {integrity: sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==} + engines: {node: '>=14'} + hasBin: true + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -4249,6 +4257,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -6489,10 +6502,10 @@ snapshots: commander@4.1.1: {} - compatfactory@3.0.0(typescript@5.8.3): + compatfactory@3.0.0(typescript@5.9.3): dependencies: helpertypes: 0.0.19 - typescript: 5.8.3 + typescript: 5.9.3 concurrently@7.6.0: dependencies: @@ -7155,9 +7168,15 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - kea-forms@3.2.0(kea@3.1.7(react@18.2.0)): + kea-forms@3.2.0(kea@3.1.7(react@18.2.0))(typescript@5.8.3): + dependencies: + kea: 3.1.7(react@18.2.0) + typescript: 5.8.3 + + kea-forms@3.2.0(kea@3.1.7(react@18.2.0))(typescript@5.9.3): dependencies: kea: 3.1.7(react@18.2.0) + typescript: 5.9.3 kea-loaders@3.1.1(kea@3.1.7(react@18.2.0)): dependencies: @@ -7181,15 +7200,15 @@ snapshots: kea: 3.1.7(react@18.2.0) kea-waitfor: 0.2.1(kea@3.1.7(react@18.2.0)) - kea-typegen@3.3.4(typescript@5.8.3): + kea-typegen@3.7.1(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 '@babel/preset-env': 7.29.0(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - prettier: 2.8.8 + prettier: 3.8.5 recast: 0.23.11 - ts-clone-node: 3.0.0(typescript@5.8.3) - typescript: 5.8.3 + ts-clone-node: 3.0.0(typescript@5.9.3) + typescript: 5.9.3 yargs: 16.2.0 transitivePeerDependencies: - supports-color @@ -8128,6 +8147,8 @@ snapshots: prettier@2.8.8: {} + prettier@3.8.5: {} + prismjs@1.30.0: {} prop-types@15.8.1: @@ -8713,10 +8734,10 @@ snapshots: trough@2.2.0: {} - ts-clone-node@3.0.0(typescript@5.8.3): + ts-clone-node@3.0.0(typescript@5.9.3): dependencies: - compatfactory: 3.0.0(typescript@5.8.3) - typescript: 5.8.3 + compatfactory: 3.0.0(typescript@5.9.3) + typescript: 5.9.3 ts-interface-checker@0.1.13: {} @@ -8741,6 +8762,8 @@ snapshots: typescript@5.8.3: {} + typescript@5.9.3: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: