An entry for the streamplace vod showcase
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Cleanup satori

+15 -642
+1 -3
apps/backend/api/package.json
··· 11 11 "@atproto/api": "^0.19.5", 12 12 "@atproto/identity": "^0.4.12", 13 13 "@atproto/syntax": "^0.5.2", 14 - "@resvg/resvg-wasm": "^2.6.2", 15 - "m3u8-parser": "^7.2.0", 16 - "satori": "^0.12.1" 14 + "m3u8-parser": "^7.2.0" 17 15 }, 18 16 "devDependencies": { 19 17 "@types/m3u8-parser": "^7.2.6"
+7 -630
apps/backend/api/src/index.ts
··· 10 10 import { AtUri } from "@atproto/syntax" 11 11 import { Parser } from "m3u8-parser" 12 12 13 - // satori and resvg are dynamically imported to avoid env access at bundle load time 14 - type SatoriType = typeof import("satori").default 15 - type ResvgType = typeof import("@resvg/resvg-wasm").Resvg 16 - 17 13 // Re-export v for sandbox validation 18 14 export { v } 19 15 ··· 36 32 "public.api.bsky.app", // Bluesky public API for profiles 37 33 "*.modal.run", // Modal accelerator endpoints 38 34 "*.mainasara.dev", // AppView (at-run hosted) 39 - "fonts.gstatic.com", // Google Fonts for OG images 40 35 "cdn.bsky.app", // Bluesky CDN for avatars 41 - "unpkg.com", // resvg WASM for OG images 42 36 ], 43 37 write: ["/tmp/vod-cache/"], 44 38 read: ["/tmp/vod-cache/"], 45 - env: ["APPVIEW_URL", "JEST_WORKER_ID"], // JEST_WORKER_ID needed by satori 39 + env: ["APPVIEW_URL"], 46 40 }, 47 41 }) 48 42 ··· 672 666 <head> 673 667 <meta charset="UTF-8"> 674 668 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 675 - <title>${escapeHtml(title)} - Streamhut</title> 669 + <title>Streamhut | ${escapeHtml(title)}</title> 676 670 677 671 <!-- Primary Meta Tags --> 678 - <meta name="title" content="${escapeHtml(title)} - Streamhut"> 672 + <meta name="title" content="Streamhut | ${escapeHtml(title)}"> 679 673 <meta name="description" content="${escapeHtml(description)}"> 680 674 681 675 <!-- Open Graph / Facebook --> 682 676 <meta property="og:type" content="${type}"> 683 677 <meta property="og:url" content="${escapeHtml(canonicalUrl)}"> 684 - <meta property="og:title" content="${escapeHtml(title)}"> 678 + <meta property="og:title" content="Streamhut | ${escapeHtml(title)}"> 685 679 <meta property="og:description" content="${escapeHtml(description)}"> 686 680 <meta property="og:image" content="${escapeHtml(imageUrl)}"> 687 681 <meta property="og:site_name" content="Streamhut"> ··· 689 683 <!-- Twitter --> 690 684 <meta name="twitter:card" content="summary_large_image"> 691 685 <meta name="twitter:url" content="${escapeHtml(canonicalUrl)}"> 692 - <meta name="twitter:title" content="${escapeHtml(title)}"> 686 + <meta name="twitter:title" content="Streamhut | ${escapeHtml(title)}"> 693 687 <meta name="twitter:description" content="${escapeHtml(description)}"> 694 688 <meta name="twitter:image" content="${escapeHtml(imageUrl)}"> 695 689 ··· 760 754 761 755 const duration = formatDurationForOG(video.duration) 762 756 const description = `${duration} video by ${creatorName}` 763 - const imageUrl = `${API_BASE_URL}/ogImageVideo?uri=${encodeURIComponent(uri)}` 757 + const imageUrl = `${API_BASE_URL}/getThumbnail?uri=${encodeURIComponent(uri)}` 764 758 const canonicalUrl = `${API_BASE_URL}/shareVideo?uri=${encodeURIComponent(uri)}` 765 759 const redirectUrl = `${FRONTEND_URL}/watch?v=${encodeURIComponent(uri)}` 766 760 ··· 806 800 807 801 const displayName = profile.displayName || `@${profile.handle}` 808 802 const description = profile.description || `Watch videos from ${displayName} on Streamhut` 809 - const imageUrl = `${API_BASE_URL}/ogImageCreator?did=${encodeURIComponent(did)}` 803 + const imageUrl = profile.avatar || "" 810 804 const canonicalUrl = `${API_BASE_URL}/shareCreator?did=${encodeURIComponent(did)}` 811 805 const redirectUrl = `${FRONTEND_URL}/profile/${encodeURIComponent(did)}` 812 806 ··· 828 822 }, 829 823 }) 830 824 831 - // ============================================================================ 832 - // OG Image Generation 833 - // ============================================================================ 834 - 835 - // Font cache 836 - let fontData: ArrayBuffer | null = null 837 - let wasmInitialized = false 838 - let satoriModule: SatoriType | null = null 839 - let ResvgClass: ResvgType | null = null 840 - 841 - async function loadFont(): Promise<ArrayBuffer> { 842 - if (fontData) return fontData 843 - 844 - // Load Inter font from Google Fonts 845 - const fontUrl = "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff" 846 - const res = await fetch(fontUrl) 847 - fontData = await res.arrayBuffer() 848 - return fontData 849 - } 850 - 851 - async function loadOGDependencies(): Promise<{ satori: SatoriType; Resvg: ResvgType }> { 852 - // Dynamic import satori 853 - if (!satoriModule) { 854 - const mod = await import("satori") 855 - satoriModule = mod.default 856 - } 857 - 858 - // Dynamic import and init resvg 859 - if (!ResvgClass) { 860 - const resvgMod = await import("@resvg/resvg-wasm") 861 - ResvgClass = resvgMod.Resvg 862 - 863 - if (!wasmInitialized) { 864 - try { 865 - // Fetch WASM from CDN 866 - const wasmRes = await fetch("https://unpkg.com/@resvg/resvg-wasm@2.6.2/index_bg.wasm") 867 - const wasmModule = await WebAssembly.compile(await wasmRes.arrayBuffer()) 868 - await resvgMod.initWasm(wasmModule) 869 - wasmInitialized = true 870 - } catch (e) { 871 - // WASM might already be initialized 872 - console.warn("WASM init warning:", e) 873 - wasmInitialized = true 874 - } 875 - } 876 - } 877 - 878 - return { satori: satoriModule, Resvg: ResvgClass } 879 - } 880 - 881 - /** 882 - * Render OG image for a video using satori 883 - */ 884 - async function renderVideoOGImage( 885 - title: string, 886 - creatorName: string, 887 - duration: string, 888 - thumbnailUrl?: string 889 - ): Promise<Uint8Array> { 890 - const [font, { satori, Resvg }] = await Promise.all([ 891 - loadFont(), 892 - loadOGDependencies(), 893 - ]) 894 - 895 - // Fetch thumbnail as base64 if available 896 - let thumbnailBase64 = "" 897 - if (thumbnailUrl) { 898 - try { 899 - const res = await fetch(thumbnailUrl) 900 - if (res.ok) { 901 - const buffer = await res.arrayBuffer() 902 - const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))) 903 - thumbnailBase64 = `data:image/jpeg;base64,${base64}` 904 - } 905 - } catch { 906 - // Skip thumbnail 907 - } 908 - } 909 - 910 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 911 - const element: any = { 912 - type: "div", 913 - props: { 914 - style: { 915 - display: "flex", 916 - flexDirection: "column", 917 - width: "100%", 918 - height: "100%", 919 - backgroundColor: "#0a0a0a", 920 - padding: "48px", 921 - }, 922 - children: [ 923 - // Thumbnail area 924 - thumbnailBase64 925 - ? { 926 - type: "img", 927 - props: { 928 - src: thumbnailBase64, 929 - style: { 930 - width: "100%", 931 - height: "340px", 932 - objectFit: "cover", 933 - borderRadius: "16px", 934 - }, 935 - }, 936 - } 937 - : { 938 - type: "div", 939 - props: { 940 - style: { 941 - width: "100%", 942 - height: "340px", 943 - backgroundColor: "#1a1a1a", 944 - borderRadius: "16px", 945 - display: "flex", 946 - alignItems: "center", 947 - justifyContent: "center", 948 - }, 949 - children: { 950 - type: "div", 951 - props: { 952 - style: { fontSize: "64px" }, 953 - children: "▶", 954 - }, 955 - }, 956 - }, 957 - }, 958 - // Title 959 - { 960 - type: "div", 961 - props: { 962 - style: { 963 - marginTop: "32px", 964 - fontSize: "48px", 965 - fontWeight: 600, 966 - color: "#ffffff", 967 - lineHeight: 1.2, 968 - overflow: "hidden", 969 - textOverflow: "ellipsis", 970 - }, 971 - children: title, 972 - }, 973 - }, 974 - // Creator and duration 975 - { 976 - type: "div", 977 - props: { 978 - style: { 979 - marginTop: "auto", 980 - display: "flex", 981 - justifyContent: "space-between", 982 - alignItems: "center", 983 - }, 984 - children: [ 985 - { 986 - type: "div", 987 - props: { 988 - style: { 989 - fontSize: "28px", 990 - color: "#a0a0a0", 991 - }, 992 - children: creatorName, 993 - }, 994 - }, 995 - { 996 - type: "div", 997 - props: { 998 - style: { 999 - display: "flex", 1000 - alignItems: "center", 1001 - gap: "16px", 1002 - }, 1003 - children: [ 1004 - { 1005 - type: "div", 1006 - props: { 1007 - style: { 1008 - fontSize: "24px", 1009 - color: "#a0a0a0", 1010 - backgroundColor: "#1a1a1a", 1011 - padding: "8px 16px", 1012 - borderRadius: "8px", 1013 - }, 1014 - children: duration, 1015 - }, 1016 - }, 1017 - { 1018 - type: "div", 1019 - props: { 1020 - style: { 1021 - fontSize: "28px", 1022 - fontWeight: 600, 1023 - color: "#ffffff", 1024 - }, 1025 - children: "Streamhut", 1026 - }, 1027 - }, 1028 - ], 1029 - }, 1030 - }, 1031 - ], 1032 - }, 1033 - }, 1034 - ], 1035 - }, 1036 - } 1037 - 1038 - const svg = await satori(element, { 1039 - width: 1200, 1040 - height: 630, 1041 - fonts: [ 1042 - { 1043 - name: "Inter", 1044 - data: font, 1045 - weight: 400, 1046 - style: "normal", 1047 - }, 1048 - { 1049 - name: "Inter", 1050 - data: font, 1051 - weight: 600, 1052 - style: "normal", 1053 - }, 1054 - ], 1055 - } 1056 - ) 1057 - 1058 - const resvg = new Resvg(svg, { 1059 - fitTo: { mode: "width", value: 1200 }, 1060 - }) 1061 - const pngData = resvg.render() 1062 - return pngData.asPng() 1063 - } 1064 - 1065 - /** 1066 - * Render OG image for a creator profile 1067 - */ 1068 - async function renderCreatorOGImage( 1069 - displayName: string, 1070 - handle: string, 1071 - description: string, 1072 - videoCount: number, 1073 - avatarUrl?: string 1074 - ): Promise<Uint8Array> { 1075 - const [font, { satori, Resvg }] = await Promise.all([ 1076 - loadFont(), 1077 - loadOGDependencies(), 1078 - ]) 1079 - 1080 - // Fetch avatar as base64 if available 1081 - let avatarBase64 = "" 1082 - if (avatarUrl) { 1083 - try { 1084 - const res = await fetch(avatarUrl) 1085 - if (res.ok) { 1086 - const buffer = await res.arrayBuffer() 1087 - const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))) 1088 - const contentType = res.headers.get("content-type") || "image/jpeg" 1089 - avatarBase64 = `data:${contentType};base64,${base64}` 1090 - } 1091 - } catch { 1092 - // Skip avatar 1093 - } 1094 - } 1095 - 1096 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 1097 - const element: any = { 1098 - type: "div", 1099 - props: { 1100 - style: { 1101 - display: "flex", 1102 - flexDirection: "column", 1103 - width: "100%", 1104 - height: "100%", 1105 - backgroundColor: "#0a0a0a", 1106 - padding: "64px", 1107 - }, 1108 - children: [ 1109 - // Header with avatar and name 1110 - { 1111 - type: "div", 1112 - props: { 1113 - style: { 1114 - display: "flex", 1115 - alignItems: "center", 1116 - gap: "32px", 1117 - }, 1118 - children: [ 1119 - avatarBase64 1120 - ? { 1121 - type: "img", 1122 - props: { 1123 - src: avatarBase64, 1124 - style: { 1125 - width: "160px", 1126 - height: "160px", 1127 - borderRadius: "80px", 1128 - objectFit: "cover", 1129 - }, 1130 - }, 1131 - } 1132 - : { 1133 - type: "div", 1134 - props: { 1135 - style: { 1136 - width: "160px", 1137 - height: "160px", 1138 - borderRadius: "80px", 1139 - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", 1140 - display: "flex", 1141 - alignItems: "center", 1142 - justifyContent: "center", 1143 - fontSize: "64px", 1144 - color: "#ffffff", 1145 - fontWeight: 600, 1146 - }, 1147 - children: displayName[0]?.toUpperCase() || "?", 1148 - }, 1149 - }, 1150 - { 1151 - type: "div", 1152 - props: { 1153 - style: { 1154 - display: "flex", 1155 - flexDirection: "column", 1156 - }, 1157 - children: [ 1158 - { 1159 - type: "div", 1160 - props: { 1161 - style: { 1162 - fontSize: "56px", 1163 - fontWeight: 600, 1164 - color: "#ffffff", 1165 - }, 1166 - children: displayName, 1167 - }, 1168 - }, 1169 - { 1170 - type: "div", 1171 - props: { 1172 - style: { 1173 - fontSize: "28px", 1174 - color: "#a0a0a0", 1175 - marginTop: "8px", 1176 - }, 1177 - children: `@${handle}`, 1178 - }, 1179 - }, 1180 - ], 1181 - }, 1182 - }, 1183 - ], 1184 - }, 1185 - }, 1186 - // Description 1187 - description 1188 - ? { 1189 - type: "div", 1190 - props: { 1191 - style: { 1192 - marginTop: "40px", 1193 - fontSize: "28px", 1194 - color: "#d0d0d0", 1195 - lineHeight: 1.5, 1196 - overflow: "hidden", 1197 - textOverflow: "ellipsis", 1198 - }, 1199 - children: description.slice(0, 200) + (description.length > 200 ? "..." : ""), 1200 - }, 1201 - } 1202 - : null, 1203 - // Footer 1204 - { 1205 - type: "div", 1206 - props: { 1207 - style: { 1208 - marginTop: "auto", 1209 - display: "flex", 1210 - justifyContent: "space-between", 1211 - alignItems: "center", 1212 - }, 1213 - children: [ 1214 - { 1215 - type: "div", 1216 - props: { 1217 - style: { 1218 - fontSize: "24px", 1219 - color: "#a0a0a0", 1220 - }, 1221 - children: `${videoCount} ${videoCount === 1 ? "video" : "videos"}`, 1222 - }, 1223 - }, 1224 - { 1225 - type: "div", 1226 - props: { 1227 - style: { 1228 - fontSize: "32px", 1229 - fontWeight: 600, 1230 - color: "#ffffff", 1231 - }, 1232 - children: "Streamhut", 1233 - }, 1234 - }, 1235 - ], 1236 - }, 1237 - }, 1238 - ].filter(Boolean), 1239 - }, 1240 - } 1241 - 1242 - const svg = await satori(element, { 1243 - width: 1200, 1244 - height: 630, 1245 - fonts: [ 1246 - { 1247 - name: "Inter", 1248 - data: font, 1249 - weight: 400, 1250 - style: "normal", 1251 - }, 1252 - { 1253 - name: "Inter", 1254 - data: font, 1255 - weight: 600, 1256 - style: "normal", 1257 - }, 1258 - ], 1259 - } 1260 - ) 1261 - 1262 - const resvg = new Resvg(svg, { 1263 - fitTo: { mode: "width", value: 1200 }, 1264 - }) 1265 - const pngData = resvg.render() 1266 - return pngData.asPng() 1267 - } 1268 - 1269 - /** 1270 - * Generate OG image for a video 1271 - */ 1272 - export const ogImageVideo = endpoint<v.InferOutput<typeof UriOnlySchema>, Response>({ 1273 - input: UriOnlySchema, 1274 - async handler({ uri }) { 1275 - // Check for cached OG image 1276 - const hash = await sha256(uri) 1277 - const ogPath = `${CACHE_DIR}/${hash}/og.png` 1278 - 1279 - try { 1280 - const cached = await Deno.readFile(ogPath) 1281 - return new Response(cached.buffer as ArrayBuffer, { 1282 - headers: { 1283 - "Content-Type": "image/png", 1284 - "Cache-Control": "public, max-age=86400, immutable", 1285 - "Access-Control-Allow-Origin": "*", 1286 - }, 1287 - }) 1288 - } catch { 1289 - // Not cached, generate 1290 - } 1291 - 1292 - // Fetch video metadata 1293 - const metaPath = `${CACHE_DIR}/${hash}/video.json` 1294 - let video: Video 1295 - try { 1296 - const content = await Deno.readTextFile(metaPath) 1297 - video = JSON.parse(content) as Video 1298 - } catch { 1299 - return new Response("Video not found", { status: 404 }) 1300 - } 1301 - 1302 - // Fetch creator profile 1303 - let creatorName = "Unknown Creator" 1304 - try { 1305 - const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(video.creator)}` 1306 - const res = await fetch(profileUrl) 1307 - if (res.ok) { 1308 - const profile = await res.json() as { displayName?: string; handle: string } 1309 - creatorName = profile.displayName || `@${profile.handle}` 1310 - } 1311 - } catch { 1312 - // Use default 1313 - } 1314 - 1315 - const duration = formatDurationForOG(video.duration) 1316 - const thumbnailUrl = `${API_BASE_URL}/getThumbnail?uri=${encodeURIComponent(uri)}` 1317 - 1318 - try { 1319 - const png = await renderVideoOGImage(video.title, creatorName, duration, thumbnailUrl) 1320 - 1321 - // Cache the result 1322 - try { 1323 - await Deno.writeFile(ogPath, png) 1324 - } catch { 1325 - // Cache write failed, continue anyway 1326 - } 1327 - 1328 - return new Response(png.buffer as ArrayBuffer, { 1329 - headers: { 1330 - "Content-Type": "image/png", 1331 - "Cache-Control": "public, max-age=86400, immutable", 1332 - "Access-Control-Allow-Origin": "*", 1333 - }, 1334 - }) 1335 - } catch (err) { 1336 - console.error("OG image generation failed:", err) 1337 - // Fallback to thumbnail 1338 - return new Response(null, { 1339 - status: 302, 1340 - headers: { 1341 - Location: thumbnailUrl, 1342 - }, 1343 - }) 1344 - } 1345 - }, 1346 - }) 1347 - 1348 - /** 1349 - * Generate OG image for a creator profile 1350 - */ 1351 - export const ogImageCreator = endpoint<v.InferOutput<typeof DidSchema>, Response>({ 1352 - input: DidSchema, 1353 - async handler({ did }) { 1354 - // Check for cached OG image 1355 - const hash = await sha256(`creator:${did}`) 1356 - const ogPath = `${CACHE_DIR}/${hash}/og-creator.png` 1357 - 1358 - try { 1359 - const cached = await Deno.readFile(ogPath) 1360 - return new Response(cached.buffer as ArrayBuffer, { 1361 - headers: { 1362 - "Content-Type": "image/png", 1363 - "Cache-Control": "public, max-age=3600", 1364 - "Access-Control-Allow-Origin": "*", 1365 - }, 1366 - }) 1367 - } catch { 1368 - // Not cached, generate 1369 - } 1370 - 1371 - // Fetch creator profile 1372 - const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 1373 - const res = await fetch(profileUrl) 1374 - 1375 - if (!res.ok) { 1376 - return new Response("Creator not found", { status: 404 }) 1377 - } 1378 - 1379 - const profile = await res.json() as { 1380 - did: string 1381 - handle: string 1382 - displayName?: string 1383 - avatar?: string 1384 - description?: string 1385 - } 1386 - 1387 - // Count videos for this creator 1388 - let videoCount = 0 1389 - try { 1390 - const entries = Deno.readDir(CACHE_DIR) 1391 - for await (const entry of entries) { 1392 - if (!entry.isDirectory) continue 1393 - try { 1394 - const metaPath = `${CACHE_DIR}/${entry.name}/video.json` 1395 - const content = await Deno.readTextFile(metaPath) 1396 - const video = JSON.parse(content) as Video 1397 - if (video.creator === did) videoCount++ 1398 - } catch { 1399 - // Skip 1400 - } 1401 - } 1402 - } catch { 1403 - // Skip count 1404 - } 1405 - 1406 - const displayName = profile.displayName || profile.handle 1407 - const description = profile.description || "" 1408 - 1409 - try { 1410 - const png = await renderCreatorOGImage( 1411 - displayName, 1412 - profile.handle, 1413 - description, 1414 - videoCount, 1415 - profile.avatar 1416 - ) 1417 - 1418 - // Cache the result 1419 - try { 1420 - await Deno.mkdir(`${CACHE_DIR}/${hash}`, { recursive: true }) 1421 - await Deno.writeFile(ogPath, png) 1422 - } catch { 1423 - // Cache write failed, continue anyway 1424 - } 1425 - 1426 - return new Response(png.buffer as ArrayBuffer, { 1427 - headers: { 1428 - "Content-Type": "image/png", 1429 - "Cache-Control": "public, max-age=3600", 1430 - "Access-Control-Allow-Origin": "*", 1431 - }, 1432 - }) 1433 - } catch (err) { 1434 - console.error("OG image generation failed:", err) 1435 - // Fallback to avatar 1436 - if (profile.avatar) { 1437 - return new Response(null, { 1438 - status: 302, 1439 - headers: { 1440 - Location: profile.avatar, 1441 - }, 1442 - }) 1443 - } 1444 - return new Response("Image generation failed", { status: 500 }) 1445 - } 1446 - }, 1447 - })
+7 -9
packages/at-run/runner/src/sandbox.ts
··· 219 219 const effectivePerms = intersectPermissions(globalPermissions, endpointPermissions) 220 220 const effectiveLimits = intersectLimits(globalLimits, endpointLimits) 221 221 222 - // Derive env permissions from secrets - only allow env vars that have secrets 222 + // Merge declared env permissions with secret keys 223 + // Bundles can declare env vars they need (e.g., JEST_WORKER_ID for satori) 224 + // and secrets are added on top of that 225 + const declaredEnv = effectivePerms.env || [] 223 226 const secretKeys = Object.keys(secrets) 224 - if (secretKeys.length > 0) { 225 - // Replace any declared env permissions with just the secret keys 226 - // This ensures bundles can only access env vars they have secrets for 227 - effectivePerms.env = secretKeys 228 - } else { 229 - // No secrets = no env access (unless explicitly empty which means none) 230 - effectivePerms.env = [] 231 - } 227 + effectivePerms.env = [...new Set([...declaredEnv, ...secretKeys])] 232 228 233 229 // Build Deno permission flags 234 230 const permFlags = permissionsToDenoCLI(effectivePerms) ··· 331 327 ...permFlags, 332 328 "-", 333 329 ] 330 + 331 + console.log(denoArgs) 334 332 335 333 // Pass secrets directly as environment variables 336 334 // (env permissions are already derived from secret keys above)