fix video chat in prod env vars

This commit is contained in:
Jeff Emmett 2025-09-02 00:43:57 +02:00
parent bab61ecf6b
commit ce0ae690fc
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
background: true,
padding: 20, // Increased padding to show full canvas
preserveAspectRatio: "true",
preserveAspectRatio: "xMidYMid meet",
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
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`, {
method: 'POST',
@ -135,22 +142,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
enable_chat: true,
enable_screenshare: true,
start_video_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"
start_audio_off: true
}
})
});
@ -205,6 +197,12 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
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`, {
method: 'POST',
headers: {
@ -212,7 +210,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
'Content-Type': 'application/json'
},
body: JSON.stringify({
room_name: shape.id,
room_name: roomName,
layout: {
preset: "active-speaker"
}

View File

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

View File

@ -1,14 +1,5 @@
/// <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 throttle from "lodash.throttle"
import { Environment } from "./types"
@ -21,45 +12,61 @@ import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
Slide: {
props: SlideShape.props,
migrations: SlideShape.migrations,
},
Prompt: {
props: PromptShape.props,
migrations: PromptShape.migrations,
},
SharedPiano: {
props: SharedPianoShape.props,
migrations: SharedPianoShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
// Lazy load TLDraw dependencies to avoid startup timeouts
let customSchema: any = null
let TLSocketRoom: any = null
async function getTldrawDependencies() {
if (!customSchema) {
const { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } = await import("@tldraw/tlschema")
customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
Slide: {
props: SlideShape.props,
migrations: SlideShape.migrations,
},
Prompt: {
props: PromptShape.props,
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:
// https://developers.cloudflare.com/durable-objects/
@ -72,7 +79,7 @@ export class TldrawDurableObject {
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
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
private roomPromise: Promise<any> | null = null
constructor(private readonly ctx: DurableObjectState, env: Environment) {
this.r2 = env.TLDRAW_BUCKET
@ -114,7 +121,7 @@ export class TldrawDurableObject {
})
})
.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)), {
headers: {
@ -206,29 +213,32 @@ export class TldrawDurableObject {
}
}
getRoom() {
async getRoom() {
const roomId = this.roomId
if (!roomId) throw new Error("Missing roomId")
if (!this.roomPromise) {
this.roomPromise = (async () => {
// Lazy load dependencies
const { customSchema, TLSocketRoom } = await getTldrawDependencies()
// fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
? ((await roomFromBucket.json()) as any)
: undefined
if (initialSnapshot) {
initialSnapshot.documents = initialSnapshot.documents.filter(
(record) => {
const shape = record.state as TLShape
(record: any) => {
const shape = record.state as any
return shape.type !== "ChatBox"
},
)
}
// 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.
return new TLSocketRoom<TLRecord, void>({
return new TLSocketRoom({
schema: customSchema,
initialSnapshot,
onDataChange: () => {

View File

@ -1,4 +1,3 @@
import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
@ -6,6 +5,17 @@ import { Environment } from "./types"
// make sure our sync durable object is made available to cloudflare
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
const securityHeaders = {
"Content-Security-Policy":
@ -27,6 +37,8 @@ const { preflight, corsify } = cors({
"https://www.jeffemmett.com",
"https://jeffemmett-canvas.jeffemmett.workers.dev",
"https://jeffemmett.com/board/*",
"http://localhost:5173",
"http://127.0.0.1:5173",
]
// 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
.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 room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
@ -110,7 +135,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.get("/uploads/:uploadId", handleAssetDownload)
// 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) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
@ -142,14 +170,26 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
try {
// Get the request body from the client
const body = await req.json()
const response = await fetch('https://api.daily.co/v1/rooms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'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()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }