Skip to content

Commit b225f08

Browse files
authored
Scene screenshots (#134)
* cache images per scene * save token by scene * bit more manageable * feat: add thumbnail generation for scene images with max width 320px * feat: add thumbnail generation to scene image upsert in frames API * feat: add thumbnail generation to SceneImage upsert logic * refactor: restructure scene panel layout for improved readability * feat: update FrameImage to use thumbnail instead of full image * fix: remove thumb suffix from subEntityId for correct route loading * feat: load thumbnail in FrameImage when thumb prop is true * store thumb, send header * feat: publish new scene image via websocket on frame image retrieval * fix: update publish_message to use nested data structure for frame info * refresh scene image * scene images * 320x320 * fix test * better placeholders * declutter * image aspect
1 parent 102b960 commit b225f08

File tree

20 files changed

+565
-173
lines changed

20 files changed

+565
-173
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ e2e/tmp/
3737

3838
gpt.txt
3939
.env
40+
.aider*

backend/app/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .fonts import * # noqa: E402, F403
2222
from .log import * # noqa: E402, F403
2323
from .repositories import * # noqa: E402, F403
24+
from .scene_images import * # noqa: E402, F403
2425
from .settings import * # noqa: E402, F403
2526
from .ssh import * # noqa: E402, F403
2627
from .templates import * # noqa: E402, F403

backend/app/api/frames.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from app.config import config
3030
from app.utils.network import is_safe_host
3131
from app.redis import get_redis
32+
from app.websockets import publish_message
3233
from . import api_with_auth, api_no_auth
3334

3435

@@ -56,8 +57,8 @@ async def api_frame_get_logs(id: int, db: Session = Depends(get_db)):
5657
return {"logs": logs}
5758

5859

59-
@api_with_auth.get("/frames/{id:int}/image_link", response_model=FrameImageLinkResponse)
60-
async def get_image_link(id: int):
60+
@api_with_auth.get("/frames/{id:int}/image_token", response_model=FrameImageLinkResponse)
61+
async def get_image_token(id: int):
6162
expire_minutes = 5
6263
now = datetime.utcnow()
6364
expire = now + timedelta(minutes=expire_minutes)
@@ -67,7 +68,7 @@ async def get_image_link(id: int):
6768
expires_in = int((expire - now).total_seconds())
6869

6970
return {
70-
"url": config.ingress_path + f"/api/frames/{id}/image?token={token}",
71+
"token": token,
7172
"expires_in": expires_in
7273
}
7374

@@ -108,6 +109,57 @@ async def api_frame_get_image(
108109

109110
if response.status_code == 200:
110111
await redis.set(cache_key, response.content, ex=86400 * 30)
112+
scene_id = response.headers.get('x-scene-id')
113+
if not scene_id:
114+
scene_id = await redis.get(f"frame:{id}:active_scene")
115+
if scene_id:
116+
scene_id = scene_id.decode('utf-8')
117+
if scene_id:
118+
# dimensions (best‑effort – don’t crash if Pillow missing)
119+
width = height = None
120+
try:
121+
from PIL import Image
122+
import io
123+
img = Image.open(io.BytesIO(response.content))
124+
width, height = img.width, img.height
125+
except Exception:
126+
pass
127+
128+
# upsert into SceneImage
129+
from app.models.scene_image import SceneImage
130+
from app.api.scene_images import _generate_thumbnail
131+
now = datetime.utcnow()
132+
img_row = (
133+
db.query(SceneImage)
134+
.filter_by(frame_id=id, scene_id=scene_id)
135+
.first()
136+
)
137+
thumb, t_width, t_height = _generate_thumbnail(response.content)
138+
if img_row:
139+
img_row.image = response.content
140+
img_row.timestamp = now
141+
img_row.width = width
142+
img_row.height = height
143+
img_row.thumb_image = thumb
144+
img_row.thumb_width = t_width
145+
img_row.thumb_height = t_height
146+
else:
147+
img_row = SceneImage(
148+
frame_id = id,
149+
scene_id = scene_id,
150+
image = response.content,
151+
timestamp = now,
152+
width = width,
153+
height = height,
154+
thumb_image = thumb,
155+
thumb_width = t_width,
156+
thumb_height = t_height
157+
)
158+
db.add(img_row)
159+
db.commit()
160+
161+
await publish_message(redis, "new_scene_image", {"frameId": id, "sceneId": scene_id, "timestamp": now.isoformat(), "width": width, "height": height})
162+
111163
return Response(content=response.content, media_type='image/png')
112164
else:
113165
raise HTTPException(status_code=response.status_code, detail="Unable to fetch image")
@@ -142,7 +194,10 @@ async def api_frame_get_state(id: int, db: Session = Depends(get_db), redis: Red
142194

143195
if response.status_code == 200:
144196
await redis.set(cache_key, response.content, ex=1)
145-
return response.json()
197+
state = response.json()
198+
if state.get('sceneId'):
199+
await redis.set(f"frame:{frame.id}:active_scene", state.get('sceneId'))
200+
return state
146201
else:
147202
last_state = await redis.get(cache_key)
148203
if last_state:
@@ -178,7 +233,10 @@ async def api_frame_get_states(id: int, db: Session = Depends(get_db), redis: Re
178233

179234
if response.status_code == 200:
180235
await redis.set(cache_key, response.content, ex=1)
181-
return response.json()
236+
states = response.json()
237+
if states.get('sceneId'):
238+
await redis.set(f"frame:{frame.id}:active_scene", states.get('sceneId'))
239+
return states
182240
else:
183241
last_states = await redis.get(cache_key)
184242
if last_states:

backend/app/api/scene_images.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# backend/app/api/scene_images.py
2+
import io
3+
from jose import JWTError, jwt
4+
5+
from fastapi import Depends, HTTPException, Request
6+
from fastapi.responses import StreamingResponse
7+
from PIL import Image, ImageDraw, ImageFont
8+
from sqlalchemy.orm import Session
9+
from app.api.auth import ALGORITHM, SECRET_KEY
10+
11+
from app.config import config
12+
from app.database import get_db
13+
from app.models.scene_image import SceneImage # created earlier
14+
from app.models.frame import Frame
15+
from . import api_no_auth
16+
17+
18+
def _generate_placeholder(
19+
width: int | None = 320,
20+
height: int | None = 240,
21+
*,
22+
font_path: str = "../frameos/assets/compiled/fonts/Ubuntu-Regular.ttf",
23+
font_size: int = 32,
24+
message: str = "No snapshot",
25+
) -> bytes:
26+
"""
27+
Return a PNG (bytes) that shows a black rectangle with centred white text.
28+
29+
Parameters
30+
----------
31+
width, height : Dimensions in pixels; defaults are 400×300.
32+
font_path : Path to a scalable font file (TTF/OTF). If omitted,
33+
Pillow’s 8‑pixel bitmap font is used.
34+
font_size : Point size for the scalable font.
35+
message : The text to write.
36+
"""
37+
width, height = int(width or 320), int(height or 240)
38+
39+
img = Image.new("RGB", (width, height), "#1f2937")
40+
draw = ImageDraw.Draw(img)
41+
font = ImageFont.truetype(font_path, font_size)
42+
43+
left, top, right, bottom = draw.textbbox((0, 0), message, font=font)
44+
text_w, text_h = right - left, bottom - top
45+
46+
draw.text(
47+
((width - text_w) / 2, (height - text_h) / 2),
48+
message,
49+
fill="white",
50+
font=font,
51+
)
52+
53+
buf = io.BytesIO()
54+
img.save(buf, format="PNG")
55+
buf.seek(0)
56+
return buf.read()
57+
58+
def _generate_thumbnail(image_bytes: bytes) -> tuple[bytes, int, int]:
59+
"""
60+
Generate a JPEG thumbnail whose width and height never exceed 320px,
61+
while preserving aspect ratio.
62+
Returns (jpeg_bytes, new_width, new_height).
63+
"""
64+
with Image.open(io.BytesIO(image_bytes)) as img:
65+
# ensure RGB
66+
if img.mode != "RGB":
67+
img = img.convert("RGB")
68+
69+
orig_width, orig_height = img.size
70+
71+
# scale factor that keeps both sides ≤ 320
72+
scale = min(320 / orig_width, 320 / orig_height, 1.0)
73+
new_width = int(round(orig_width * scale))
74+
new_height = int(round(orig_height * scale))
75+
76+
img = img.resize((new_width, new_height), Image.ANTIALIAS)
77+
78+
buf = io.BytesIO()
79+
img.save(buf, format="JPEG")
80+
buf.seek(0)
81+
return buf.read(), new_width, new_height
82+
83+
84+
@api_no_auth.get("/frames/{frame_id}/scene_images/{scene_id}")
85+
async def get_scene_image(
86+
frame_id: int,
87+
scene_id: str,
88+
token: str,
89+
request: Request,
90+
db: Session = Depends(get_db),
91+
):
92+
"""
93+
Fetch the latest stored SceneImage.
94+
If none exists, return a placeholder that matches the frame’s native
95+
width/height so the UI keeps its layout.
96+
"""
97+
98+
if config.HASSIO_RUN_MODE != 'ingress':
99+
try:
100+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
101+
if payload.get("sub") != f"frame={frame_id}":
102+
raise HTTPException(status_code=401, detail="Unauthorized")
103+
except JWTError:
104+
raise HTTPException(status_code=401, detail="Unauthorized")
105+
106+
107+
img_row: SceneImage | None = (
108+
db.query(SceneImage)
109+
.filter_by(frame_id=frame_id, scene_id=scene_id)
110+
.order_by(SceneImage.timestamp.desc())
111+
.first()
112+
)
113+
114+
if img_row:
115+
# fresh snapshot found, generate and save thumbnail if not present
116+
if not getattr(img_row, 'thumb_image', None):
117+
thumb, t_width, t_height = _generate_thumbnail(img_row.image)
118+
img_row.thumb_image = thumb
119+
img_row.thumb_width = t_width
120+
img_row.thumb_height = t_height
121+
db.add(img_row)
122+
db.commit()
123+
db.refresh(img_row)
124+
if request.query_params.get("thumb") == "1":
125+
return StreamingResponse(
126+
io.BytesIO(img_row.thumb_image),
127+
media_type="image/jpeg",
128+
headers={"Cache-Control": "no-cache"},
129+
)
130+
else:
131+
return StreamingResponse(
132+
io.BytesIO(img_row.image),
133+
media_type="image/png",
134+
headers={"Cache-Control": "no-cache"},
135+
)
136+
137+
frame: Frame | None = db.get(Frame, frame_id)
138+
if frame is None:
139+
raise HTTPException(status_code=404, detail="Frame not found")
140+
141+
if request.query_params.get("thumb") == "1":
142+
scale = min(320 / frame.width, 320 / frame.height, 1.0)
143+
new_width = int(round(frame.width * scale))
144+
new_height = int(round(frame.height * scale))
145+
else:
146+
new_width = frame.width
147+
new_height = frame.height
148+
149+
png = _generate_placeholder(new_width, new_height)
150+
return StreamingResponse(io.BytesIO(png), media_type="image/png")

backend/app/api/templates.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ async def get_template(template_id: str, db: Session = Depends(get_db)):
239239
d = template.to_dict()
240240
return d
241241

242-
@api_with_auth.get("/templates/{template_id}/image_link", response_model=TemplateImageLinkResponse)
243-
async def get_image_link(template_id: str):
242+
@api_with_auth.get("/templates/{template_id}/image_token", response_model=TemplateImageLinkResponse)
243+
async def get_image_token(template_id: str):
244244
expire_minutes = 5
245245
now = datetime.utcnow()
246246
expire = now + timedelta(minutes=expire_minutes)
@@ -249,7 +249,7 @@ async def get_image_link(template_id: str):
249249
expires_in = int((expire - now).total_seconds())
250250

251251
return {
252-
"url": config.ingress_path + f"/api/templates/{template_id}/image?token={token}",
252+
"token": token,
253253
"expires_in": expires_in
254254
}
255255

backend/app/api/tests/test_frames.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ async def test_api_frame_get_image_cached(async_client, db, redis):
4444
await redis.set(cache_key, b'cached_image_data')
4545

4646
# First, get the image link (which gives us the token)
47-
image_link_resp = await async_client.get(f'/api/frames/{frame.id}/image_link')
48-
assert image_link_resp.status_code == 200
49-
link_info = image_link_resp.json()
50-
image_url = link_info['url']
47+
image_token_resp = await async_client.get(f'/api/frames/{frame.id}/image_token')
48+
assert image_token_resp.status_code == 200
49+
link_info = image_token_resp.json()
50+
token = link_info['token']
51+
image_url = f'/api/frames/{frame.id}/image?token={token}'
5152

5253
# Append t=-1 to force returning the cached data
5354
image_url += "&t=-1"

backend/app/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
from .metrics import * # noqa: F403
66
from .repository import * # noqa: F403
77
from .settings import * # noqa: F403
8+
from .scene_image import * # noqa: F403
89
from .template import * # noqa: F403
910
from .user import * # noqa: F403

backend/app/models/log.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ async def process_log(db: Session, redis: Redis, frame: Frame, log: dict | list)
6161

6262
assert isinstance(log, dict), f"Log must be a dict, got {type(log)}"
6363

64-
changes: dict[str, Any] = {}
6564
event = log.get('event', 'log')
65+
66+
if event in ("render:scene", "render:sceneChange", "event:setCurrentScene"):
67+
scene_id = log.get("sceneId") or log.get("scene") or log.get("id")
68+
if scene_id:
69+
await redis.set(f"frame:{frame.id}:active_scene", scene_id, ex=300)
70+
71+
changes: dict[str, Any] = {}
6672
if event == 'render':
6773
changes['status'] = 'preparing'
6874
if event == 'render:device':

backend/app/models/scene_image.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import uuid
2+
from sqlalchemy import Integer, String, LargeBinary, DateTime, ForeignKey, UniqueConstraint, func
3+
from sqlalchemy.orm import relationship, backref, mapped_column
4+
from app.database import Base
5+
6+
class SceneImage(Base):
7+
"""
8+
Stores the *latest* rendered image for every (frame_id, scene_id) pair.
9+
The row is *up‑serted* whenever a fresher snapshot is available.
10+
"""
11+
__tablename__ = "scene_image"
12+
__table_args__ = (UniqueConstraint("frame_id", "scene_id", name="u_frame_scene"),)
13+
14+
id = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
15+
timestamp = mapped_column(DateTime, nullable=False, default=func.current_timestamp())
16+
frame_id = mapped_column(Integer, ForeignKey("frame.id"), nullable=False)
17+
scene_id = mapped_column(String(128), nullable=False)
18+
19+
image = mapped_column(LargeBinary, nullable=False)
20+
width = mapped_column(Integer)
21+
height = mapped_column(Integer)
22+
23+
thumb_image = mapped_column(LargeBinary, nullable=True)
24+
thumb_width = mapped_column(Integer)
25+
thumb_height = mapped_column(Integer)
26+
27+
# handy backref – Frame.scene_images
28+
frame = relationship("Frame", backref=backref("scene_images", lazy=True))
29+
30+
def to_dict(self):
31+
return {
32+
"id": self.id,
33+
"timestamp": self.timestamp.isoformat(),
34+
"frame_id": self.frame_id,
35+
"scene_id": self.scene_id,
36+
"width": self.width,
37+
"height": self.height,
38+
}

backend/app/schemas/frames.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class FrameMetricsResponse(BaseModel):
9696
metrics: List[Dict[str, Any]]
9797

9898
class FrameImageLinkResponse(BaseModel):
99-
url: str
99+
token: str
100100
expires_in: int
101101

102102
class FrameStateResponse(RootModel):

0 commit comments

Comments
 (0)