Skip to content

Commit e004db5

Browse files
committed
frame image
1 parent 4d2a6ad commit e004db5

File tree

6 files changed

+208
-117
lines changed

6 files changed

+208
-117
lines changed

backend/app/api/frames.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from datetime import datetime, timedelta
12
import io
23
import json
34
import os
45
import shlex
56
import asyncio
7+
from jose import JWTError, jwt
68
from http import HTTPStatus
79
from tempfile import NamedTemporaryFile
810

@@ -19,7 +21,9 @@
1921
from app.codegen.scene_nim import write_scene_nim
2022
from app.utils.ssh_utils import get_ssh_connection, exec_command, remove_ssh_connection
2123
from scp import SCPClient
22-
from . import private_api
24+
25+
from app.api.auth import ALGORITHM, SECRET_KEY, get_current_user
26+
from . import private_api, public_api
2327

2428

2529
@private_api.get("/frames")
@@ -58,8 +62,30 @@ async def api_frame_get_logs(id: int, db: Session = Depends(get_db)):
5862
status_code=HTTPStatus.NOT_FOUND)
5963

6064

61-
@private_api.get("/frames/{id}/image")
62-
async def api_frame_get_image(id: int, request: Request, db: Session = Depends(get_db)):
65+
@private_api.get("/frames/{id}/image_link")
66+
async def get_image_link(id: int, user=Depends(get_current_user)):
67+
expire_minutes = 5
68+
now = datetime.utcnow()
69+
expire = now + timedelta(minutes=expire_minutes)
70+
to_encode = {"sub": str(id), "exp": expire}
71+
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
72+
73+
expires_in = int((expire - now).total_seconds())
74+
75+
return {
76+
"url": f"/api/frames/{id}/image?token={token}",
77+
"expires_in": expires_in
78+
}
79+
80+
@public_api.get("/frames/{id}/image")
81+
async def api_frame_get_image(id: int, token: str, request: Request, db: Session = Depends(get_db)):
82+
try:
83+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
84+
if payload.get("sub") != str(id):
85+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
86+
except JWTError:
87+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
88+
6389
frame = db.query(Frame).get(id)
6490
if frame is None:
6591
return JSONResponse(content={'error': 'Frame not found'}, status_code=HTTPStatus.NOT_FOUND)
@@ -70,6 +96,8 @@ async def api_frame_get_image(id: int, request: Request, db: Session = Depends(g
7096
url += "?k=" + frame.frame_access_key
7197

7298
try:
99+
# We can check if a cached version exists
100+
# TODO: no request object here
73101
if request.query_params.get('t') == '-1':
74102
last_image = await redis.get(cache_key)
75103
if last_image:
@@ -82,9 +110,6 @@ async def api_frame_get_image(id: int, request: Request, db: Session = Depends(g
82110
await redis.set(cache_key, response.content, ex=86400 * 30)
83111
return Response(content=response.content, media_type='image/png')
84112
else:
85-
last_image = await redis.get(cache_key)
86-
if last_image:
87-
return Response(content=last_image, media_type='image/png')
88113
return JSONResponse(content={"error": "Unable to fetch image"}, status_code=response.status_code)
89114

90115
except httpx.ReadTimeout:
@@ -94,7 +119,6 @@ async def api_frame_get_image(id: int, request: Request, db: Session = Depends(g
94119
return JSONResponse(content={'error': 'Internal Server Error', 'message': str(e)},
95120
status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
96121

97-
98122
@private_api.get("/frames/{id}/state")
99123
async def api_frame_get_state(id: int, db: Session = Depends(get_db)):
100124
frame = db.query(Frame).get(id)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useActions, useValues } from 'kea'
2+
import clsx from 'clsx'
3+
import { framesModel } from '../models/framesModel'
4+
import { useEffect, useState } from 'react'
5+
6+
export interface FrameImageProps extends React.HTMLAttributes<HTMLDivElement> {
7+
frameId: number
8+
className?: string
9+
/** If true, user can click on the image to request a refresh of the signed URL */
10+
refreshable?: boolean
11+
}
12+
13+
/**
14+
* Consolidated Image component:
15+
* - Takes a frameId
16+
* - Uses framesModel to get and update the frame's image
17+
* - Shows loading states based on image load or frame readiness
18+
* - Optionally allows clicking the image container to refresh the image link if `refreshable` is true
19+
*/
20+
export function FrameImage({ frameId, className, refreshable = true, ...props }: FrameImageProps) {
21+
const { getFrameImage, frames } = useValues(framesModel)
22+
const { updateFrameImage } = useActions(framesModel)
23+
24+
const [isLoading, setIsLoading] = useState(true)
25+
26+
const imageUrl = getFrameImage(frameId)
27+
const frame = frames[frameId]
28+
29+
useEffect(() => {
30+
updateFrameImage(frameId, false)
31+
}, [!!imageUrl])
32+
33+
useEffect(() => {
34+
// Whenever the image URL changes, we consider the image as loading again
35+
// because the <img> will re-attempt to load the new URL.
36+
if (imageUrl) {
37+
setIsLoading(true)
38+
}
39+
}, [imageUrl])
40+
41+
console.log({ imageUrl })
42+
43+
// Determine if we should show the fade-in-out or loading cursor
44+
const visiblyLoading = (isLoading || frame?.status !== 'ready') && frame?.interval > 5
45+
46+
const handleRefreshClick = () => {
47+
if (refreshable) {
48+
updateFrameImage(frameId)
49+
}
50+
}
51+
52+
return (
53+
<div
54+
className={clsx(
55+
'max-w-full max-h-full w-full h-full flex items-center justify-center',
56+
visiblyLoading ? 'continuous-fade-in-out' : null,
57+
visiblyLoading ? 'cursor-wait' : refreshable ? 'cursor-pointer' : 'cursor-default',
58+
className
59+
)}
60+
onClick={handleRefreshClick}
61+
title={refreshable ? 'Click to refresh' : undefined}
62+
{...props}
63+
>
64+
{frame && (
65+
<img
66+
className={clsx('rounded-lg', refreshable ? 'rounded-tl-none max-w-full max-h-full' : 'w-full')}
67+
src={imageUrl ?? undefined}
68+
onLoad={() => setIsLoading(false)}
69+
onError={() => setIsLoading(false)}
70+
style={{
71+
...(frame.width && frame.height
72+
? {
73+
aspectRatio:
74+
frame.rotate === 90 || frame.rotate === 270
75+
? `${frame.height} / ${frame.width}`
76+
: `${frame.width} / ${frame.height}`,
77+
maxWidth: '100%',
78+
maxHeight: '100%',
79+
// If you need a fixed width/height based on rotation:
80+
// width: frame.rotate === 90 || frame.rotate === 270 ? frame.height : frame.width,
81+
// height: 'auto',
82+
}
83+
: {}),
84+
}}
85+
alt=""
86+
/>
87+
)}
88+
</div>
89+
)
90+
}

frontend/src/models/framesModel.tsx

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
2-
32
import { loaders } from 'kea-loaders'
43
import { FrameScene, FrameType } from '../types'
54
import { socketLogic } from '../scenes/socketLogic'
6-
75
import type { framesModelType } from './framesModelType'
86
import { router } from 'kea-router'
97
import { sanitizeScene } from '../scenes/frame/frameLogic'
108
import { apiFetch } from '../utils/apiFetch'
119

10+
export interface FrameImageInfo {
11+
url: string
12+
expiresAt: number
13+
}
14+
1215
export const framesModel = kea<framesModelType>([
1316
connect({ logic: [socketLogic] }),
1417
path(['src', 'models', 'framesModel']),
@@ -18,8 +21,10 @@ export const framesModel = kea<framesModelType>([
1821
redeployFrame: (id: number) => ({ id }),
1922
restartFrame: (id: number) => ({ id }),
2023
renderFrame: (id: number) => ({ id }),
21-
updateFrameImage: (id: number) => ({ id }),
24+
updateFrameImage: (id: number, force = true) => ({ id, force }),
2225
deleteFrame: (id: number) => ({ id }),
26+
setFrameImageInfo: (id: number, imageInfo: FrameImageInfo) => ({ id, imageInfo }),
27+
updateFrameImageTimestamp: (id: number) => ({ id }),
2328
}),
2429
loaders(({ values }) => ({
2530
frames: [
@@ -29,13 +34,16 @@ export const framesModel = kea<framesModelType>([
2934
try {
3035
const response = await apiFetch(`/api/frames/${id}`)
3136
if (!response.ok) {
32-
throw new Error('Failed to fetch logs')
37+
throw new Error('Failed to fetch frame')
3338
}
3439
const data = await response.json()
3540
const frame = data.frame as FrameType
3641
return {
3742
...values.frames,
38-
frame: { ...frame, scenes: frame.scenes?.map((scene) => sanitizeScene(scene as FrameScene, frame)) },
43+
[frame.id]: {
44+
...frame,
45+
scenes: frame.scenes?.map((scene) => sanitizeScene(scene as FrameScene, frame)),
46+
},
3947
}
4048
} catch (error) {
4149
console.error(error)
@@ -49,7 +57,16 @@ export const framesModel = kea<framesModelType>([
4957
throw new Error('Failed to fetch frames')
5058
}
5159
const data = await response.json()
52-
return Object.fromEntries((data.frames as FrameType[]).map((frame) => [frame.id, frame]))
60+
const framesDict = Object.fromEntries(
61+
(data.frames as FrameType[]).map((frame) => [
62+
frame.id,
63+
{
64+
...frame,
65+
scenes: frame.scenes?.map((scene) => sanitizeScene(scene as FrameScene, frame)),
66+
},
67+
])
68+
)
69+
return framesDict
5370
} catch (error) {
5471
console.error(error)
5572
return values.frames
@@ -60,7 +77,7 @@ export const framesModel = kea<framesModelType>([
6077
})),
6178
reducers(() => ({
6279
frames: [
63-
{} as Record<string, FrameType>,
80+
{} as Record<number, FrameType>,
6481
{
6582
[socketLogic.actionTypes.newFrame]: (state, { frame }) => ({ ...state, [frame.id]: frame }),
6683
[socketLogic.actionTypes.updateFrame]: (state, { frame }) => ({ ...state, [frame.id]: frame }),
@@ -71,11 +88,20 @@ export const framesModel = kea<framesModelType>([
7188
},
7289
},
7390
],
91+
frameImageInfos: [
92+
{} as Record<number, FrameImageInfo>,
93+
{
94+
setFrameImageInfo: (state, { id, imageInfo }) => ({ ...state, [id]: imageInfo }),
95+
},
96+
],
7497
frameImageTimestamps: [
75-
{} as Record<string, number>,
98+
{} as Record<number, number>,
7699
{
77-
updateFrameImage: (state, { id }) =>
78-
state[id] === Math.floor(Date.now() / 1000) ? state : { ...state, [id]: Math.floor(Date.now() / 1000) },
100+
updateFrameImageTimestamp: (state, { id }) => {
101+
const nowSeconds = Math.floor(Date.now() / 1000)
102+
// Only update if it's different, to ensure a re-render
103+
return state[id] === nowSeconds ? state : { ...state, [id]: nowSeconds }
104+
},
79105
},
80106
],
81107
})),
@@ -88,18 +114,31 @@ export const framesModel = kea<framesModelType>([
88114
) as FrameType[],
89115
],
90116
getFrameImage: [
91-
(s) => [s.frameImageTimestamps],
92-
(frameImageTimestamps) => {
93-
return (id) => {
94-
return `/api/frames/${id}/image?t=${frameImageTimestamps[id] ?? -1}`
117+
(s) => [s.frameImageInfos, s.frameImageTimestamps],
118+
(frameImageInfos, frameImageTimestamps) => {
119+
return (id: number) => {
120+
const info = frameImageInfos[id]
121+
if (!info) {
122+
return null
123+
}
124+
125+
const now = Math.floor(Date.now() / 1000)
126+
if (!info.expiresAt || now >= info.expiresAt) {
127+
// URL expired or invalid
128+
return null
129+
}
130+
131+
const timestamp = frameImageTimestamps[id] ?? -1
132+
// Append timestamp as a cache-buster
133+
return `${info.url}${info.url.includes('?') ? '&' : '?'}t=${timestamp}`
95134
}
96135
},
97136
],
98137
}),
99138
afterMount(({ actions }) => {
100139
actions.loadFrames()
101140
}),
102-
listeners(({ props, actions }) => ({
141+
listeners(({ actions, values }) => ({
103142
renderFrame: async ({ id }) => {
104143
await apiFetch(`/api/frames/${id}/event/render`, { method: 'POST' })
105144
},
@@ -115,6 +154,33 @@ export const framesModel = kea<framesModelType>([
115154
router.actions.push('/')
116155
}
117156
},
157+
updateFrameImage: async ({ id, force }) => {
158+
// Check if we have a valid URL
159+
const imageUrl = values.getFrameImage(id)
160+
if (imageUrl) {
161+
// The URL is still valid, no need to refetch new signed URL
162+
// Just update timestamp to refresh (force reload)
163+
if (force) {
164+
actions.updateFrameImageTimestamp(id)
165+
}
166+
return
167+
}
168+
169+
// Need a new signed URL
170+
const resp = await apiFetch(`/api/frames/${id}/image_link`)
171+
if (resp.ok) {
172+
const data = await resp.json()
173+
const expiresAt = Math.floor(Date.now() / 1000) + data.expires_in
174+
const imageInfo: FrameImageInfo = { url: data.url, expiresAt }
175+
actions.setFrameImageInfo(id, imageInfo)
176+
// Update timestamp to ensure a new request even if the URL is same
177+
if (force) {
178+
actions.updateFrameImageTimestamp(id)
179+
}
180+
} else {
181+
console.error('Failed to get image link for frame', id)
182+
}
183+
},
118184
[socketLogic.actionTypes.newLog]: ({ log }) => {
119185
if (log.type === 'webhook') {
120186
const parsed = JSON.parse(log.line)

0 commit comments

Comments
 (0)