Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ VITE_SUPABASE_URL="https://supabase.revisit.dev"
VITE_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYwNjgwODAwLCJleHAiOjE5MTg0NDcyMDB9.IohiSvWUtjylJgn4gMrK3aYfnbz-hUmyb3h87DrQvTc"

VITE_OPENAI_API_URL="https://apps.vdl.sci.utah.edu/openai-proxy" # Your proxy URL for OpenAI API requests

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ supabase/volumes/*
!supabase/volumes/db/
supabase/volumes/db/data
!supabase/volumes/api/

.firebaserc
20 changes: 20 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"functions": [
{
"source": "functions",
"codebase": "default",
"disallowLegacyRuntimeConfig": true,
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
"*.local"
],
"predeploy": [
"yarn --cwd \"$RESOURCE_DIR\" lint",
"yarn --cwd \"$RESOURCE_DIR\" build"
]
}
]
}
10 changes: 10 additions & 0 deletions functions/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
VITE_FIREBASE_CONFIG='
{
apiKey: "AIzaSyAm9QtUgx1lYPDeE0vKLN-lK17WfUGVkLo",
authDomain: "revisit-utah.firebaseapp.com",
projectId: "revisit-utah",
storageBucket: "revisit-utah.appspot.com",
messagingSenderId: "811568460432",
appId: "1:811568460432:web:995f6b4f1fc8042b5dde15"
}
'
35 changes: 35 additions & 0 deletions functions/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module.exports = {
root: true,
env: {
es6: true,
node: true,
},
extends: [
'airbnb-base',
'eslint:recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['tsconfig.json', 'tsconfig.dev.json'],
sourceType: 'module',
},
ignorePatterns: [
'/lib/**/*', // Ignore built files.
'/generated/**/*', // Ignore generated files.
],
plugins: [
'@typescript-eslint',
'import',
],
rules: {
quotes: ['error', 'single'],
'import/no-unresolved': 0,
indent: ['error', 2],
'valid-jsdoc': 'off',
'require-jsdoc': 'off',
'import/prefer-default-export': 'off',
'no-restricted-syntax': 'off',
},
};
10 changes: 10 additions & 0 deletions functions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map

# TypeScript v1 declaration files
typings/

# Node.js dependency directory
node_modules/
*.local
61 changes: 61 additions & 0 deletions functions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Firebase Functions Setup

## Prerequisites

- [Firebase CLI](https://firebase.google.com/docs/cli) installed and authenticated
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) installed and authenticated
- Access to your Firebase project

## 1. Configure Environment Variables

Update to `.env` in the **functions** directory with your required values:

```
VITE_FIREBASE_CONFIG='
{
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.appspot.com",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID"
}
'
```

You can find these values in the Firebase console under **Project Settings > Your apps**.
They should be the same as the ones in your `.env` from your root folder.

## 2. Grant IAM Permissions

These steps are required to allow Firebase/Eventarc to respond to Cloud Storage events.

### 2a. Get your project number

```bash
gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)"
```

Replace `YOUR_PROJECT_ID` with your Firebase project ID. Save the output — you'll use it as `PROJECT_NUMBER` in the next steps.

### 2b. Grant Storage Admin to the Eventarc service account

```bash
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:service-PROJECT_NUMBER@gcp-sa-eventarc.iam.gserviceaccount.com" \
--role="roles/storage.admin"
```

## 3. Deploy Functions

Install dependencies:

```bash
yarn
```

Then deploy:

```bash
yarn deploy
```
36 changes: 36 additions & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "functions",
"scripts": {
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts .",
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "yarn build && firebase emulators:start --only functions",
"shell": "yarn build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "22"
},
"main": "lib/index.js",
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"firebase-admin": "^13.6.0",
"firebase-functions": "^7.0.0",
"fluent-ffmpeg": "^2.1.3",
"hjson": "^3.2.2"
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.27",
"@types/hjson": "^2.4.6",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"eslint": "^8.9.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"firebase-functions-test": "^3.4.1",
"typescript": "^5.7.3"
},
"private": true
}
92 changes: 92 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { setGlobalOptions } from 'firebase-functions';
import { onObjectFinalized } from 'firebase-functions/v2/storage';
import * as logger from 'firebase-functions/logger';
import * as admin from 'firebase-admin';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
import ffmpeg from 'fluent-ffmpeg';
import { parse as hjsonParse } from 'hjson';

const firebaseConfig = hjsonParse(process.env.VITE_FIREBASE_CONFIG ?? '{}');
const BUCKET: string = firebaseConfig.storageBucket;

admin.initializeApp();
setGlobalOptions({ maxInstances: 5 });
ffmpeg.setFfmpegPath(ffmpegInstaller.path);

const SCREEN_RECORDING_PATH = /^[^/]+\/screenRecording\//;
const WEBM_COMPATIBLE_CODECS = new Set(['vp8', 'vp9', 'av1', 'opus', 'vorbis']);

function isWebmCopyCompatible(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) {
resolve(false);
return;
}
const streams = metadata.streams ?? [];
resolve(streams.every((s) => !s.codec_name || WEBM_COMPATIBLE_CODECS.has(s.codec_name)));
});
});
}

export const convertScreenRecording = onObjectFinalized(
{
bucket: BUCKET, memory: '1GiB', timeoutSeconds: 60, maxInstances: 10,
},
async (event) => {
const filePath = event.data.name;

if (!SCREEN_RECORDING_PATH.test(filePath)) return;

// Prevent re-trigger loop: uploading back to the same path fires onObjectFinalized again
if (event.data.metadata?.converted === 'true') {
logger.info(`Skipping already-converted: ${filePath}`);
return;
}

const fileName = path.basename(filePath);
const tmpInput = path.join(os.tmpdir(), fileName);
const tmpOutput = path.join(os.tmpdir(), `${fileName}.tmp`);
const bucket = admin.storage().bucket(BUCKET);

try {
logger.info(`Downloading ${filePath}`);
await bucket.file(filePath).download({ destination: tmpInput });

if (!await isWebmCopyCompatible(tmpInput)) {
logger.info(`Skipping: codecs not compatible with WebM stream copy: ${filePath}`);
return;
}

logger.info('Converting to webm');
await new Promise<void>((resolve, reject) => {
ffmpeg(tmpInput)
.outputOptions('-c', 'copy', '-f', 'webm')
.output(tmpOutput)
.on('end', () => resolve())
.on('error', (err: Error) => reject(err))
.run();
});

logger.info(`Uploading ${filePath}`);
await bucket.upload(tmpOutput, {
destination: filePath,
metadata: { contentType: 'video/webm', metadata: { converted: 'true' } },
});

logger.info(`Done: ${filePath}`);
} catch (err) {
logger.error(`Conversion failed for ${filePath}`, err);
throw err;
} finally {
for (const f of [tmpInput, tmpOutput]) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch { /* ignore */ }
}
}
},
);
5 changes: 5 additions & 0 deletions functions/tsconfig.dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"include": [
".eslintrc.js"
]
}
15 changes: 15 additions & 0 deletions functions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "nodenext",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "ESNext",
},
"compileOnSave": true,
"include": ["src"],
}
Loading
Loading