- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events) - Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests) - Phase 2: Remove all `any` types from production code with proper typed replacements - Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility - Phase 4: Verify .DS_Store housekeeping (already clean) 0 TypeScript errors, 583/583 tests passing.
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
import { GoogleGenAI } from '@google/genai'
|
|
import { supabase } from './supabase'
|
|
import type { Database } from './supabase'
|
|
|
|
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
|
|
|
|
function getAI(): GoogleGenAI {
|
|
const apiKey = process.env['GEMINI_API_KEY']
|
|
if (!apiKey) {
|
|
throw new Error('GEMINI_API_KEY environment variable is not set')
|
|
}
|
|
return new GoogleGenAI({ apiKey })
|
|
}
|
|
|
|
export interface GeneratedImage {
|
|
buffer: Buffer
|
|
mimeType: string
|
|
}
|
|
|
|
export interface VideoGenerationResult {
|
|
workoutId: string
|
|
trainerId: string
|
|
videoUrl: string
|
|
videoPath: string
|
|
}
|
|
|
|
export async function generateTrainerAvatar(
|
|
prompt: string,
|
|
_trainerName: string
|
|
): Promise<GeneratedImage[]> {
|
|
const config = {
|
|
imageConfig: {
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
personGeneration: 'ALLOW_ADULT',
|
|
},
|
|
responseModalities: ['IMAGE', 'TEXT'],
|
|
}
|
|
|
|
const response = await getAI().models.generateContentStream({
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
config,
|
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
})
|
|
|
|
const images: GeneratedImage[] = []
|
|
|
|
for await (const chunk of response) {
|
|
const parts = chunk.candidates?.[0]?.content?.parts
|
|
if (!parts) continue
|
|
|
|
for (const part of parts) {
|
|
if (part.inlineData) {
|
|
images.push({
|
|
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
|
mimeType: part.inlineData.mimeType || 'image/png',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return images
|
|
}
|
|
|
|
export async function saveAvatarToStorage(
|
|
trainerId: string,
|
|
imageBuffer: Buffer,
|
|
filename: string
|
|
): Promise<string> {
|
|
const path = `${trainerId}/${filename}`
|
|
|
|
const { error } = await supabase.storage
|
|
.from('trainer-avatars')
|
|
.upload(path, imageBuffer, {
|
|
contentType: 'image/png',
|
|
upsert: true,
|
|
})
|
|
|
|
if (error) throw new Error(`Failed to upload avatar: ${error.message}`)
|
|
|
|
const { data: { publicUrl } } = supabase.storage
|
|
.from('trainer-avatars')
|
|
.getPublicUrl(path)
|
|
|
|
return publicUrl
|
|
}
|
|
|
|
export async function generateWorkoutVideo(
|
|
workoutId: string,
|
|
trainerId: string,
|
|
_trainerAvatarUrl: string,
|
|
workoutTitle: string,
|
|
exercises: { name: string; duration: number }[]
|
|
): Promise<VideoGenerationResult> {
|
|
const exerciseList = exercises.map(e => e.name).join(', ')
|
|
|
|
const prompt = `${workoutTitle}. Exercises: ${exerciseList}. The video must loop seamlessly - start and end in the exact same standing position with arms at sides. Person should appear energetic and properly demonstrate each exercise. 16:9 aspect ratio.`
|
|
|
|
const operation = await getAI().models.generateVideos({
|
|
model: 'veo-3.1-fast-generate-preview',
|
|
source: { prompt },
|
|
config: {
|
|
numberOfVideos: 1,
|
|
aspectRatio: '16:9',
|
|
resolution: '1080p',
|
|
personGeneration: 'dont_allow',
|
|
durationSeconds: 8,
|
|
},
|
|
})
|
|
|
|
let currentOperation = operation
|
|
while (!currentOperation.done) {
|
|
await new Promise(resolve => setTimeout(resolve, 10000))
|
|
currentOperation = await getAI().operations.getVideosOperation({ operation: currentOperation })
|
|
}
|
|
|
|
const videoUri = currentOperation.response?.generatedVideos?.[0]?.video?.uri
|
|
if (!videoUri) throw new Error('No video generated')
|
|
|
|
const response = await fetch(`${videoUri}&key=${process.env['GEMINI_API_KEY']}`)
|
|
const arrayBuffer = await response.arrayBuffer()
|
|
const videoBuffer = Buffer.from(arrayBuffer)
|
|
|
|
const videoPath = `${trainerId}/${workoutId}.mp4`
|
|
const { error } = await supabase.storage
|
|
.from('trainers-videos')
|
|
.upload(videoPath, videoBuffer, {
|
|
contentType: 'video/mp4',
|
|
upsert: true,
|
|
})
|
|
|
|
if (error) throw new Error(`Failed to upload video: ${error.message}`)
|
|
|
|
const { data: { publicUrl } } = supabase.storage
|
|
.from('trainers-videos')
|
|
.getPublicUrl(videoPath)
|
|
|
|
return { workoutId, trainerId, videoUrl: publicUrl, videoPath }
|
|
}
|
|
|
|
export async function isWorkoutInProgram(_workoutId: string): Promise<boolean> {
|
|
// Legacy program_workouts table removed — workout programs now use program_tabatas
|
|
return false
|
|
}
|
|
|
|
export async function isVideoGeneratedForWorkout(_workoutId: string): Promise<boolean> {
|
|
// Legacy program_workouts table removed — video status tracked in workouts table
|
|
return false
|
|
}
|
|
|
|
export async function getWorkoutVideoStatus(workoutId: string): Promise<{
|
|
hasVideo: boolean
|
|
videoUrl: string | null
|
|
inProgram: boolean
|
|
videoGenerated: boolean
|
|
}> {
|
|
const workoutResult = await supabase
|
|
.from('workouts')
|
|
.select('video_url')
|
|
.eq('id', workoutId)
|
|
.single()
|
|
|
|
const workoutVideoUrl = (workoutResult.data as WorkoutRow | null)?.video_url ?? null
|
|
|
|
return {
|
|
hasVideo: !!workoutVideoUrl,
|
|
videoUrl: workoutVideoUrl,
|
|
inProgram: false,
|
|
videoGenerated: false,
|
|
}
|
|
}
|