refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- 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.
This commit is contained in:
171
admin-web/lib/ai.ts
Normal file
171
admin-web/lib/ai.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user