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
28 changes: 28 additions & 0 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions backend/app/api/tests/test_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion e2e/frontend-visual/tests/visual-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ async function expandDashboardScene(page: Page): Promise<void> {
.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<void> {
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,15 @@
"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",
"sass": "^1.64.2",
"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": {
Expand Down
188 changes: 188 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading