Skip to content

rebase : betterauth + devenv + r2#183

Open
sr2echa wants to merge 23 commits into
mainfrom
feat/rebase
Open

rebase : betterauth + devenv + r2#183
sr2echa wants to merge 23 commits into
mainfrom
feat/rebase

Conversation

@sr2echa
Copy link
Copy Markdown
Member

@sr2echa sr2echa commented May 3, 2026

Summary

  • Refactor codebase
  • Make initial arch changes to ai agent
  • replace file serve with cloudflare r2 object store
  • switch back to betterauth for better auth
  • stable consistent dev environment

Comment on lines +357 to +423
async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

if (!filename) {
res.status(400).json({ error: "Filename is required" });
const { assetId } = req.params;
const body = req.body as Buffer;
if (!assetId) {
res.status(400).json({ error: "assetId is required" });
return;
}
if (!Buffer.isBuffer(body) || body.length === 0) {
res.status(400).json({ error: "File body is required" });
return;
}

const decodedFilename = decodeURIComponent(filename);
const sourcePath = path.resolve("out", decodedFilename);
try {
const { rows } = await db.query<{ r2_key: string; mime_type: string; content_hash: string | null }>(
`SELECT r2_key, mime_type, content_hash
FROM assets
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
[assetId, userId],
);

if (rows.length === 0) {
res.status(404).json({ error: "Asset not found" });
return;
}

// Security check - ensure source file is in the out directory
if (!sourcePath.startsWith(path.resolve("out"))) {
res.status(403).json({ error: "Access denied" });
return;
const r2Key = rows[0].r2_key;
const fallbackMimeType = rows[0].mime_type || "application/octet-stream";
const reqMimeType = req.headers["content-type"];
const contentType = typeof reqMimeType === "string" ? reqMimeType : fallbackMimeType;

await r2.send(
new PutObjectCommand({
Bucket: ASSETS_BUCKET,
Key: r2Key,
Body: body,
ContentType: contentType,
}),
);

const actualFileSize = body.length;
await db.query(
`UPDATE assets
SET file_size = $3
WHERE id = $1 AND user_id = $2`,
[assetId, userId, actualFileSize],
);
if (rows[0].content_hash) {
await db.query(
`UPDATE r2_objects
SET file_size = $2
WHERE content_hash = $1`,
[rows[0].content_hash, actualFileSize],
);
}

res.json({ success: true });
} catch (err) {
console.error("upload-bytes error:", err);
res.status(500).json({ error: "Failed to upload file" });
}
},
Comment thread app/videorender/videorender.ts Fixed
Comment on lines +431 to +480
app.post("/assets/complete-upload", async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

const { assetId, width, height, durationInSeconds } = req.body as {
assetId: string;
width?: number;
height?: number;
durationInSeconds?: number;
};

if (!assetId) {
res.status(400).json({ error: "assetId is required" });
return;
}

try {
const { rows: lookup } = await db.query(
"SELECT r2_key, content_hash FROM assets WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL",
[assetId, userId],
);

if (lookup.length === 0) {
res.status(404).json({ error: "Asset not found" });
return;
}

// Generate new filename with timestamp and suffix
const timestamp = Date.now();
const sourceExtension = path.extname(decodedFilename);
const sourceNameWithoutExt = path.basename(decodedFilename, sourceExtension);
const newFilename = `${sourceNameWithoutExt}_${suffix}_${timestamp}${sourceExtension}`;
const destPath = path.resolve("out", newFilename);
const contentHash: string = lookup[0].content_hash;

// Copy the file
fs.copyFileSync(sourcePath, destPath);
// Mark the shared R2 object as confirmed (idempotent — no-op if already ready)
await db.query("UPDATE r2_objects SET status='ready' WHERE content_hash=$1 AND status='pending'", [contentHash]);

const fileStats = fs.statSync(destPath);
const fileUrl = `/media/${encodeURIComponent(newFilename)}`;
const fullUrl = `http://localhost:${port}${fileUrl}`;
const { rows } = await db.query(
`UPDATE assets
SET status='ready', width=$3, height=$4, duration_seconds=$5
WHERE id=$1 AND user_id=$2
RETURNING *`,
[assetId, userId, width ?? null, height ?? null, durationInSeconds ?? null],
);

console.log(`📋 File cloned: ${decodedFilename} -> ${newFilename}`);
console.log(`✅ Upload complete: ${assetId}`);
res.json({ asset: { ...rows[0], assetUrl: getAssetUrl(assetId) } });
} catch (err) {
console.error("complete-upload error:", err);
res.status(500).json({ error: "Failed to complete upload" });
}
});
Comment on lines +550 to +630
app.delete("/projects/:projectId", async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

const { projectId } = req.params;
const client = await db.connect();
const r2KeysToDelete = new Set<string>();

try {
await client.query("BEGIN");

const { rows: projectRows } = await client.query("SELECT id FROM projects WHERE id=$1 AND user_id=$2 FOR UPDATE", [
projectId,
userId,
]);
if (projectRows.length === 0) {
await client.query("ROLLBACK");
res.status(404).json({ error: "Project not found" });
return;
}

if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found" });
const { rows: assetRows } = await client.query<AssetDeleteRow>(
`SELECT r2_key, content_hash, status
FROM assets
WHERE project_id=$1 AND user_id=$2 AND deleted_at IS NULL
FOR UPDATE`,
[projectId, userId],
);

await client.query(
`UPDATE assets
SET deleted_at = now()
WHERE project_id=$1 AND user_id=$2 AND deleted_at IS NULL`,
[projectId, userId],
);

const handledHashes = new Set<string>();
const handledDirectKeys = new Set<string>();

for (const assetRow of assetRows) {
if (!assetRow.r2_key || assetRow.status !== "ready") continue;

if (assetRow.content_hash) {
if (handledHashes.has(assetRow.content_hash)) continue;
handledHashes.add(assetRow.content_hash);
} else {
if (handledDirectKeys.has(assetRow.r2_key)) continue;
handledDirectKeys.add(assetRow.r2_key);
}

const deleteObject = await shouldDeleteR2Object(client, assetRow);
if (deleteObject) {
r2KeysToDelete.add(assetRow.r2_key);
}
}

await client.query("DELETE FROM projects WHERE id=$1 AND user_id=$2", [projectId, userId]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
console.error("delete project error:", err);
res.status(500).json({ error: "Failed to delete project" });
return;
} finally {
client.release();
}

for (const r2Key of r2KeysToDelete) {
try {
await r2.send(new DeleteObjectCommand({ Bucket: ASSETS_BUCKET, Key: r2Key }));
console.log(`🗑️ R2 object removed after project delete: ${r2Key}`);
} catch (r2Err) {
console.warn(`⚠️ Failed to delete R2 object ${r2Key} after project delete:`, r2Err);
}
}

res.status(204).send();
});
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly upgrades the application's infrastructure by migrating to better-auth, implementing Cloudflare R2 storage with content-addressed deduplication, and enhancing the AI chat with multi-tab history and timeline snapshots. It also introduces stack-wide Zod schema validation and optimized Docker configurations. Feedback highlights critical improvements for production stability, including streaming large file uploads to prevent OOM errors, configuring SSL for the renderer's database pool, and adopting distributed rate limiting. Additionally, suggestions were made to use structuredClone for state snapshots and to ensure transition effects dynamically scale with composition resolution.

Comment thread app/videorender/videorender.ts Outdated

app.put(
"/assets/upload/:assetId",
express.raw({ type: "*/*", limit: "500mb" }),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Buffering large file uploads (up to 500MB) in memory using express.raw poses a significant risk of Out-Of-Memory (OOM) crashes, especially under concurrent load on a server with limited RAM (8GB). It is highly recommended to remove this middleware and stream the request body directly to Cloudflare R2 using the @aws-sdk/lib-storage Upload utility, similar to how renders are handled later in this file.

Comment thread app/components/chat/ChatBox.tsx Outdated
];
}

const captureSnapshot = (): TimelineState => JSON.parse(JSON.stringify(latestTimelineRef.current));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use structuredClone for deep cloning the timeline state. It is a native, more efficient way to perform deep clones compared to the JSON.parse(JSON.stringify()) pattern, and it correctly handles more complex data types if they are added to the state in the future.

Suggested change
const captureSnapshot = (): TimelineState => JSON.parse(JSON.stringify(latestTimelineRef.current));
const captureSnapshot = (): TimelineState => structuredClone(latestTimelineRef.current);

Comment thread app/video-compositions/VideoPlayer.tsx Outdated
return cast(flip());
case "iris":
return iris({ width: 1000, height: 1000 });
return cast(iris({ width: 1920, height: 1080 }));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The iris transition dimensions are hardcoded to 1920x1080. This may result in incorrect visual effects if the composition resolution is different (e.g., vertical video). Use the compositionWidth and compositionHeight props to ensure the effect scales correctly with the project resolution.

Suggested change
return cast(iris({ width: 1920, height: 1080 }));
return cast(iris({ width: compositionWidth ?? 1920, height: compositionHeight ?? 1080 }));

Comment thread app/videorender/videorender.ts Outdated

// ─── Database pool ────────────────────────────────────────────────────────────

const db = new Pool({ connectionString: process.env.DATABASE_URL!.trim() });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The database pool configuration in the renderer does not account for SSL requirements, which are handled in the main backend and auth server. This will cause connection failures when connecting to remote databases (like Supabase) that require SSL.

const db = new Pool({
  connectionString: process.env.DATABASE_URL!.trim(),
  ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false,
});

Comment thread backend/ai/routes.py Outdated
# iterate at ~1 req/sec during a session. Revisit with Redis once we move to multi-worker.
_RATE_LIMIT_WINDOW_SECONDS = 60
_RATE_LIMIT_MAX_REQUESTS = 60
_recent_requests: dict[str, deque[float]] = defaultdict(deque)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In-memory rate limiting using a global dictionary (_recent_requests) is not effective when running multiple worker processes (e.g., uvicorn --workers N). In a production-ready environment, a distributed store like Redis should be used to maintain rate limit state consistently across all processes.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 3, 2026

Not up to standards ⛔

🔴 Issues 20 critical · 2 high · 6 medium

Alerts:
⚠ 28 issues (≤ 0 issues of at least minor severity)

Results:
28 new issues

Category Results
Security 2 high
20 critical
6 medium

View in Codacy

🟢 Metrics 1099 complexity · 36 duplication

Metric Results
Complexity 1099
Duplication 36

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Comment on lines +395 to +479
app.post("/assets/initiate-upload", async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

const { assetId, filename, fileSize, mimeType, mediaType, contentHash, projectId } = req.body as {
assetId: string;
filename: string;
fileSize: number;
mimeType: string;
mediaType: string;
contentHash: string; // SHA-256 hex from browser
projectId?: string | null;
};

if (!assetId || !filename || !mimeType || !contentHash) {
res.status(400).json({ error: "assetId, filename, mimeType, and contentHash are required" });
return;
}

const ext = path.extname(filename).toLowerCase();
// Content-addressed key — shared across all users with the same file
const r2Key = `objects/${contentHash}${ext}`;
try {
const { filename, originalName, suffix } = req.body;
// ── Dedup check ──────────────────────────────────────────────────────────
// Upsert into r2_objects. If a row already exists with this hash, the
// no-op UPDATE still triggers RETURNING so we get back the current status.
const { rows: objRows } = await db.query<{ status: string }>(
`INSERT INTO r2_objects (content_hash, r2_key, file_size, mime_type, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (content_hash)
DO UPDATE SET content_hash = EXCLUDED.content_hash
RETURNING status`,
[contentHash, r2Key, fileSize || 0, mimeType],
);

const objectStatus = objRows[0]?.status ?? "pending";

if (!filename) {
res.status(400).json({ error: "Filename is required" });
if (objectStatus === "ready") {
// ── Fast-path: file already in R2 — create per-user asset record only ──
await db.query(
`INSERT INTO assets
(id, user_id, project_id, content_hash, r2_key, filename, file_size,
mime_type, media_type, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'ready')
ON CONFLICT (id) DO NOTHING`,
[
assetId,
userId,
projectId ?? null,
contentHash,
r2Key,
filename,
fileSize || null,
mimeType,
mediaType || null,
],
);

console.log(`⚡ Dedup hit: ${filename} (${contentHash.slice(0, 8)}…)`);
res.json({ alreadyExists: true, assetId, assetUrl: getAssetUrl(assetId) });
return;
}

const decodedFilename = decodeURIComponent(filename);
const sourcePath = path.resolve("out", decodedFilename);
// ── Normal path: create asset record in 'uploading' state ───────────────
await db.query(
`INSERT INTO assets
(id, user_id, project_id, content_hash, r2_key, filename, file_size,
mime_type, media_type, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'uploading')
ON CONFLICT (id) DO UPDATE
SET content_hash=$4, r2_key=$5, filename=$6, file_size=$7,
mime_type=$8, media_type=$9, status='uploading'`,
[assetId, userId, projectId ?? null, contentHash, r2Key, filename, fileSize || null, mimeType, mediaType || null],
);

console.log(`📤 Upload initiated: ${filename} → ${r2Key}`);
res.json({ assetId, r2Key, assetUrl: getAssetUrl(assetId) });
} catch (err) {
console.error("initiate-upload error:", err);
res.status(500).json({ error: "Failed to initiate upload" });
}
});
Comment on lines +871 to +937
app.post("/assets/:assetId/clone", async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

const { assetId } = req.params;
const { suffix } = req.body as { suffix?: string };

app.post("/render", async (req, res) => {
try {
// Get input props from POST body
const inputProps = {
timelineData: req.body.timelineData,
durationInFrames: req.body.durationInFrames,
compositionWidth: req.body.compositionWidth,
compositionHeight: req.body.compositionHeight,
getPixelsPerSecond: req.body.getPixelsPerSecond,
isRendering: true,
};

// console.log("Input props:", typeof inputProps.compositionWidth);
console.log("Input props:", JSON.stringify(inputProps, null, 2));
// Get the composition you want to render
const composition = await selectComposition({
serveUrl: bundleLocation,
id: compositionId,
inputProps,
});
const { rows } = await db.query("SELECT * FROM assets WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL", [
assetId,
userId,
]);

// const maxFrames = Math.min(composition.durationInFrames, 150); // Max 5 seconds at 30fps
// console.log(`Starting ULTRA low-resource render. Limiting to ${maxFrames} frames (${maxFrames / 30}s)`);
if (rows.length === 0) {
res.status(404).json({ error: "Source asset not found" });
return;
}

// Render optimized for 4vCPU, 8GB RAM server
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation: `out/${compositionId}.mp4`,
inputProps,
// Optimized settings for server hardware
concurrency: 3, // Use 3 cores, leave 1 for system
verbose: true,
logLevel: "info", // More detailed logging for server monitoring
// Balanced encoding settings for server performance
ffmpegOverride: ({ args }) => {
return [
...args,
"-preset",
"fast", // Good balance of speed and quality
"-crf",
"28", // Better quality than ultrafast setting
"-threads",
"3", // Use 3 threads for encoding
"-tune",
"film", // Better quality for general content
"-x264-params",
"ref=3:me=hex:subme=6:trellis=1", // Better quality settings
"-g",
"30", // Standard keyframe interval
"-bf",
"2", // Allow some B-frames for better compression
"-maxrate",
"5M", // Limit bitrate to prevent memory issues
"-bufsize",
"10M", // Buffer size for rate control
];
},
timeoutInMilliseconds: 900000, // 15 minute timeout for longer videos
});
const source = rows[0];
const sourceKey: string = source.r2_key;
const ext = path.extname(sourceKey);
const newAssetId = generateUUID();
const newR2Key = `${userId}/${newAssetId}${ext}`;

console.log("✅ Render completed successfully");
res.sendFile(path.resolve(`out/${compositionId}.mp4`));
await r2.send(
new CopyObjectCommand({
CopySource: `${ASSETS_BUCKET}/${sourceKey}`,
Bucket: ASSETS_BUCKET,
Key: newR2Key,
}),
);

const newFilename = suffix ? `${path.basename(source.filename, ext)} ${suffix}${ext}` : source.filename;

const { rows: newRows } = await db.query(
`INSERT INTO assets
(id, user_id, project_id, r2_key, filename, file_size, mime_type,
media_type, duration_seconds, width, height, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'ready')
RETURNING *`,
[
newAssetId,
userId,
source.project_id,
newR2Key,
newFilename,
source.file_size,
source.mime_type,
source.media_type,
source.duration_seconds,
source.width,
source.height,
],
);
// Note: cloned assets intentionally have no content_hash since they represent
// a new physical object in R2 (e.g. audio extracted from video).

console.log(`📋 Asset cloned: ${assetId} → ${newAssetId}`);
res.json({ asset: { ...newRows[0], assetUrl: getAssetUrl(newAssetId) } });
} catch (err) {
console.error("clone asset error:", err);
res.status(500).json({ error: "Failed to clone asset" });
}
});
Comment on lines +271 to +301
app.get("/renderer/assets/:assetId/file", async (req: Request, res: Response): Promise<void> => {
const { assetId } = req.params;
if (!UUID_PATTERN.test(assetId)) {
res.status(400).end();
return;
}
try {
if (!req.file) {
res.status(400).json({ error: "No file uploaded" });
const { rows } = await db.query<{ r2_key: string; mime_type: string }>(
`SELECT r2_key, mime_type FROM assets WHERE id = $1 AND deleted_at IS NULL AND status = 'ready' LIMIT 1`,
[assetId],
);
if (rows.length === 0) {
res.status(404).end();
return;
}
const object = await r2.send(new GetObjectCommand({ Bucket: ASSETS_BUCKET, Key: rows[0].r2_key }));
const body = object.Body as NodeJS.ReadableStream | undefined;
if (!body || typeof (body as { pipe?: unknown }).pipe !== "function") {
res.status(500).end();
return;
}
res.setHeader("Content-Type", object.ContentType || rows[0].mime_type || "application/octet-stream");
res.setHeader("Cache-Control", "private, max-age=300");
if (typeof object.ContentLength === "number") res.setHeader("Content-Length", String(object.ContentLength));
body.on("error", () => { if (!res.headersSent) res.status(500).end(); else res.end(); });
body.pipe(res);
} catch (err) {
console.error("renderer asset proxy error:", err);
res.status(500).end();
}
});
export function sanitizeExportFileName(name: string, ext: string): string {
const dotExt = ext.startsWith(".") ? ext : `.${ext}`;
let base = name.trim().replace(/\.(mp4|webm|mov|mkv)$/i, "");
base = base.replace(/[^\w.\- ]+/g, "_").replace(/\s+/g, "-").replace(/^-+|-+$/g, "");
Comment on lines +1371 to +1425
app.get("/projects/:projectId/renders", async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

console.log("✅ Render completed successfully");
res.sendFile(path.resolve(`out/${compositionId}.mp4`));
const { projectId } = req.params;
if (!UUID_PATTERN.test(projectId)) {
res.status(400).json({ error: "Invalid project id" });
return;
}
if (!(await assertProjectOwned(userId, projectId))) {
res.status(404).json({ error: "Project not found" });
return;
}

try {
const { rows } = await db.query(
`SELECT id, file_name, codec, width, height, r2_video_key, r2_thumb_key, created_at
FROM project_renders
WHERE project_id = $1::uuid AND user_id = $2
ORDER BY created_at DESC
LIMIT 50`,
[projectId, userId],
);

const renders = await Promise.all(
rows.map(async (row) => {
const urls = await signRenderAssetUrls({
file_name: row.file_name as string,
r2_video_key: row.r2_video_key as string,
r2_thumb_key: (row.r2_thumb_key as string | null) ?? null,
codec: row.codec as string,
});
return {
id: String(row.id),
fileName: row.file_name as string,
codec: row.codec as string,
width: row.width as number,
height: row.height as number,
createdAt: (row.created_at as Date).toISOString(),
thumbnailUrl: urls.thumbnailUrl,
previewUrl: urls.previewUrl,
downloadUrl: urls.downloadUrl,
};
}),
);

res.json({ renders });
} catch (err) {
console.error("❌ Render failed:", err);
console.error("export history error:", err);
res.status(500).json({ error: "Failed to load export history" });
}
});
Comment on lines +1431 to +1481
async (req: Request, res: Response): Promise<void> => {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}

const { projectId, renderId } = req.params;
if (!UUID_PATTERN.test(projectId) || !UUID_PATTERN.test(renderId)) {
res.status(400).json({ error: "Invalid id" });
return;
}
if (!(await assertProjectOwned(userId, projectId))) {
res.status(404).json({ error: "Project not found" });
return;
}

// Clean up failed renders
try {
const outputPath = `out/${compositionId}.mp4`;
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
console.log("🧹 Cleaned up partial file");
const { rows } = await db.query(
`SELECT r2_video_key, r2_thumb_key
FROM project_renders
WHERE id = $1::uuid AND project_id = $2::uuid AND user_id = $3`,
[renderId, projectId, userId],
);
if (rows.length === 0) {
res.status(404).json({ error: "Export not found" });
return;
}

const videoKey = rows[0].r2_video_key as string;
const thumbKey = (rows[0].r2_thumb_key as string | null) ?? null;

await db.query(
`DELETE FROM project_renders
WHERE id = $1::uuid AND project_id = $2::uuid AND user_id = $3`,
[renderId, projectId, userId],
);

try {
await deleteRenderR2Objects(userId, videoKey, thumbKey);
console.log(`🗑️ Export deleted: ${renderId} (${videoKey})`);
} catch (r2Err) {
console.warn(`⚠️ Export removed from DB but R2 delete failed for ${renderId}:`, r2Err);
}
} catch (cleanupErr) {
console.warn("⚠️ Could not clean up:", cleanupErr);

res.status(204).send();
} catch (err) {
console.error("delete export error:", err);
res.status(500).json({ error: "Failed to delete export" });
}
},
@robinroy03
Copy link
Copy Markdown
Member

https://diffshub.com/trykimu/videoeditor/pull/183
github renderer is just a disappointment at this scale

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants