fix video chat in prod env vars

This commit is contained in:
Jeff Emmett 2025-09-02 00:43:57 +02:00
parent 1d1b64fe7c
commit 9a9cab1b8e
5 changed files with 126 additions and 78 deletions

View File

@ -38,7 +38,7 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise<string |
scale: 0.5, // Reduced scale to make image smaller scale: 0.5, // Reduced scale to make image smaller
background: true, background: true,
padding: 20, // Increased padding to show full canvas padding: 20, // Increased padding to show full canvas
preserveAspectRatio: "true", preserveAspectRatio: "xMidYMid meet",
bounds: bounds, // Export the entire canvas bounds bounds: bounds, // Export the entire canvas bounds
}, },
}); });

View File

@ -121,7 +121,14 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
// Create room name based on board ID and timestamp // Create room name based on board ID and timestamp
const roomName = `board_${boardId}_${Date.now()}`; // Sanitize boardId to only use valid Daily.co characters (A-Z, a-z, 0-9, '-', '_')
const sanitizedBoardId = boardId.replace(/[^A-Za-z0-9\-_]/g, '_');
const roomName = `board_${sanitizedBoardId}_${Date.now()}`;
console.log('🔧 Room name generation:');
console.log('Original boardId:', boardId);
console.log('Sanitized boardId:', sanitizedBoardId);
console.log('Final roomName:', roomName);
const response = await fetch(`${workerUrl}/daily/rooms`, { const response = await fetch(`${workerUrl}/daily/rooms`, {
method: 'POST', method: 'POST',
@ -135,22 +142,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
enable_chat: true, enable_chat: true,
enable_screenshare: true, enable_screenshare: true,
start_video_off: true, start_video_off: true,
start_audio_off: true, start_audio_off: true
enable_recording: "cloud",
start_cloud_recording: true,
start_cloud_recording_opts: {
layout: {
preset: "active-speaker"
},
format: "mp4",
mode: "audio-only"
},
// Transcription settings
transcription: {
enabled: true,
auto_start: false
},
recordings_template: "{room_name}/audio-{epoch_time}.mp4"
} }
}) })
}); });
@ -205,6 +197,12 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
const apiKey = import.meta.env.VITE_DAILY_API_KEY; const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try { try {
// Extract room name from URL (same as transcription methods)
const roomName = shape.props.roomUrl.split('/').pop();
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
const response = await fetch(`${workerUrl}/daily/recordings/start`, { const response = await fetch(`${workerUrl}/daily/recordings/start`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -212,7 +210,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
room_name: shape.id, room_name: roomName,
layout: { layout: {
preset: "active-speaker" preset: "active-speaker"
} }

View File

@ -20,7 +20,7 @@ export const saveToPdf = async (editor: Editor) => {
scale: 2, scale: 2,
background: true, background: true,
padding: 0, padding: 0,
preserveAspectRatio: "true", preserveAspectRatio: "xMidYMid meet",
}, },
}) })

View File

@ -1,14 +1,5 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
import {
TLRecord,
TLShape,
createTLSchema,
defaultBindingSchemas,
defaultShapeSchemas,
shapeIdValidator,
} from "@tldraw/tlschema"
import { AutoRouter, IRequest, error } from "itty-router" import { AutoRouter, IRequest, error } from "itty-router"
import throttle from "lodash.throttle" import throttle from "lodash.throttle"
import { Environment } from "./types" import { Environment } from "./types"
@ -21,45 +12,61 @@ import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
// add custom shapes and bindings here if needed: // Lazy load TLDraw dependencies to avoid startup timeouts
export const customSchema = createTLSchema({ let customSchema: any = null
shapes: { let TLSocketRoom: any = null
...defaultShapeSchemas,
ChatBox: { async function getTldrawDependencies() {
props: ChatBoxShape.props, if (!customSchema) {
migrations: ChatBoxShape.migrations, const { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } = await import("@tldraw/tlschema")
},
VideoChat: { customSchema = createTLSchema({
props: VideoChatShape.props, shapes: {
migrations: VideoChatShape.migrations, ...defaultShapeSchemas,
}, ChatBox: {
Embed: { props: ChatBoxShape.props,
props: EmbedShape.props, migrations: ChatBoxShape.migrations,
migrations: EmbedShape.migrations, },
}, VideoChat: {
Markdown: { props: VideoChatShape.props,
props: MarkdownShape.props, migrations: VideoChatShape.migrations,
migrations: MarkdownShape.migrations, },
}, Embed: {
MycrozineTemplate: { props: EmbedShape.props,
props: MycrozineTemplateShape.props, migrations: EmbedShape.migrations,
migrations: MycrozineTemplateShape.migrations, },
}, Markdown: {
Slide: { props: MarkdownShape.props,
props: SlideShape.props, migrations: MarkdownShape.migrations,
migrations: SlideShape.migrations, },
}, MycrozineTemplate: {
Prompt: { props: MycrozineTemplateShape.props,
props: PromptShape.props, migrations: MycrozineTemplateShape.migrations,
migrations: PromptShape.migrations, },
}, Slide: {
SharedPiano: { props: SlideShape.props,
props: SharedPianoShape.props, migrations: SlideShape.migrations,
migrations: SharedPianoShape.migrations, },
}, Prompt: {
}, props: PromptShape.props,
bindings: defaultBindingSchemas, migrations: PromptShape.migrations,
}) },
SharedPiano: {
props: SharedPianoShape.props,
migrations: SharedPianoShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
}
if (!TLSocketRoom) {
const syncCore = await import("@tldraw/sync-core")
TLSocketRoom = syncCore.TLSocketRoom
}
return { customSchema, TLSocketRoom }
}
// each whiteboard room is hosted in a DurableObject: // each whiteboard room is hosted in a DurableObject:
// https://developers.cloudflare.com/durable-objects/ // https://developers.cloudflare.com/durable-objects/
@ -72,7 +79,7 @@ export class TldrawDurableObject {
private roomId: string | null = null private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever // when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once. // load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null private roomPromise: Promise<any> | null = null
constructor(private readonly ctx: DurableObjectState, env: Environment) { constructor(private readonly ctx: DurableObjectState, env: Environment) {
this.r2 = env.TLDRAW_BUCKET this.r2 = env.TLDRAW_BUCKET
@ -114,7 +121,7 @@ export class TldrawDurableObject {
}) })
}) })
.post("/room/:roomId", async (request) => { .post("/room/:roomId", async (request) => {
const records = (await request.json()) as TLRecord[] const records = (await request.json()) as any[]
return new Response(JSON.stringify(Array.from(records)), { return new Response(JSON.stringify(Array.from(records)), {
headers: { headers: {
@ -206,29 +213,32 @@ export class TldrawDurableObject {
} }
} }
getRoom() { async getRoom() {
const roomId = this.roomId const roomId = this.roomId
if (!roomId) throw new Error("Missing roomId") if (!roomId) throw new Error("Missing roomId")
if (!this.roomPromise) { if (!this.roomPromise) {
this.roomPromise = (async () => { this.roomPromise = (async () => {
// Lazy load dependencies
const { customSchema, TLSocketRoom } = await getTldrawDependencies()
// fetch the room from R2 // fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`) const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room // if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot) ? ((await roomFromBucket.json()) as any)
: undefined : undefined
if (initialSnapshot) { if (initialSnapshot) {
initialSnapshot.documents = initialSnapshot.documents.filter( initialSnapshot.documents = initialSnapshot.documents.filter(
(record) => { (record: any) => {
const shape = record.state as TLShape const shape = record.state as any
return shape.type !== "ChatBox" return shape.type !== "ChatBox"
}, },
) )
} }
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections. // create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though. // it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({ return new TLSocketRoom({
schema: customSchema, schema: customSchema,
initialSnapshot, initialSnapshot,
onDataChange: () => { onDataChange: () => {

View File

@ -1,4 +1,3 @@
import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router" import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads" import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types" import { Environment } from "./types"
@ -6,6 +5,17 @@ import { Environment } from "./types"
// make sure our sync durable object is made available to cloudflare // make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from "./TldrawDurableObject" export { TldrawDurableObject } from "./TldrawDurableObject"
// Lazy load heavy dependencies to avoid startup timeouts
let handleUnfurlRequest: any = null
async function getUnfurlHandler() {
if (!handleUnfurlRequest) {
const unfurl = await import("cloudflare-workers-unfurl")
handleUnfurlRequest = unfurl.handleUnfurlRequest
}
return handleUnfurlRequest
}
// Define security headers // Define security headers
const securityHeaders = { const securityHeaders = {
"Content-Security-Policy": "Content-Security-Policy":
@ -27,6 +37,8 @@ const { preflight, corsify } = cors({
"https://www.jeffemmett.com", "https://www.jeffemmett.com",
"https://jeffemmett-canvas.jeffemmett.workers.dev", "https://jeffemmett-canvas.jeffemmett.workers.dev",
"https://jeffemmett.com/board/*", "https://jeffemmett.com/board/*",
"http://localhost:5173",
"http://127.0.0.1:5173",
] ]
// Always allow if no origin (like from a local file) // Always allow if no origin (like from a local file)
@ -94,6 +106,19 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}) })
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get("/connect/:roomId", (request, env) => { .get("/connect/:roomId", (request, env) => {
// Check if this is a WebSocket upgrade request
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader === "websocket") {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
}
// Handle regular GET requests
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id) const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, { return room.fetch(request.url, {
@ -110,7 +135,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.get("/uploads/:uploadId", handleAssetDownload) .get("/uploads/:uploadId", handleAssetDownload)
// bookmarks need to extract metadata from pasted URLs: // bookmarks need to extract metadata from pasted URLs:
.get("/unfurl", handleUnfurlRequest) .get("/unfurl", async (request, env) => {
const handler = await getUnfurlHandler()
return handler(request, env)
})
.get("/room/:roomId", (request, env) => { .get("/room/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
@ -142,14 +170,26 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
} }
try { try {
// Get the request body from the client
const body = await req.json()
const response = await fetch('https://api.daily.co/v1/rooms', { const response = await fetch('https://api.daily.co/v1/rooms', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}` 'Authorization': `Bearer ${apiKey}`
} },
body: JSON.stringify(body)
}) })
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json() const data = await response.json()
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }