Spaces:
Running
Running
| import type { | |
| SoraGenerationParams, | |
| SoraGenerationResult, | |
| SoraJob, | |
| NarrativeGenerationParams, | |
| NarrativeGenerationResult, | |
| StoryChoice | |
| } from '$lib/types'; | |
| const API_BASE = 'https://api.openai.com/v1'; | |
| /** | |
| * Create a Sora video generation job | |
| */ | |
| export async function createSoraVideo( | |
| apiKey: string, | |
| params: SoraGenerationParams | |
| ): Promise<SoraJob> { | |
| const formData = new FormData(); | |
| formData.append('model', params.model || 'sora-2'); | |
| formData.append('prompt', params.prompt); | |
| formData.append('seconds', String(params.seconds || 8)); | |
| if (params.size) { | |
| formData.append('size', params.size); | |
| } | |
| if (params.inputReference) { | |
| formData.append('input_reference', params.inputReference, 'reference.jpg'); | |
| } | |
| const response = await fetch(`${API_BASE}/videos`, { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${apiKey}` | |
| }, | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json().catch(() => ({ error: { message: response.statusText } })); | |
| throw new Error(`Failed to create video: ${error.error?.message || response.statusText}`); | |
| } | |
| return await response.json(); | |
| } | |
| /** | |
| * Poll a Sora video job until completion | |
| */ | |
| export async function pollSoraJob( | |
| apiKey: string, | |
| jobId: string, | |
| onProgress?: (progress: number) => void | |
| ): Promise<SoraJob> { | |
| const POLL_INTERVAL = 2000; // 2 seconds | |
| const MAX_ATTEMPTS = 300; // 10 minutes max | |
| for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { | |
| const response = await fetch(`${API_BASE}/videos/${jobId}`, { | |
| headers: { | |
| 'Authorization': `Bearer ${apiKey}` | |
| } | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to poll job: ${response.statusText}`); | |
| } | |
| const job: SoraJob = await response.json(); | |
| if (onProgress && job.progress !== undefined) { | |
| onProgress(job.progress); | |
| } | |
| if (job.status === 'completed') { | |
| return job; | |
| } | |
| if (job.status === 'failed') { | |
| throw new Error(job.error?.message || 'Video generation failed'); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); | |
| } | |
| throw new Error('Video generation timed out'); | |
| } | |
| /** | |
| * Download the generated video content | |
| */ | |
| export async function downloadSoraVideo( | |
| apiKey: string, | |
| jobId: string | |
| ): Promise<string> { | |
| const response = await fetch(`${API_BASE}/videos/${jobId}/content?variant=video`, { | |
| headers: { | |
| 'Authorization': `Bearer ${apiKey}` | |
| } | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to download video: ${response.statusText}`); | |
| } | |
| // Create a blob URL for the video | |
| const blob = await response.blob(); | |
| return URL.createObjectURL(blob); | |
| } | |
| /** | |
| * Complete Sora video generation workflow | |
| */ | |
| export async function generateSoraVideo( | |
| apiKey: string, | |
| params: SoraGenerationParams, | |
| onProgress?: (progress: number) => void | |
| ): Promise<SoraGenerationResult> { | |
| // Create the job | |
| const job = await createSoraVideo(apiKey, params); | |
| // Poll until complete | |
| const completedJob = await pollSoraJob(apiKey, job.id, onProgress); | |
| // Download the video | |
| const videoUrl = await downloadSoraVideo(apiKey, completedJob.id); | |
| return { | |
| videoUrl, | |
| jobId: job.id | |
| }; | |
| } | |
| /** | |
| * Generate narrative and choices using GPT-4 | |
| */ | |
| export async function generateNarrative( | |
| apiKey: string, | |
| params: NarrativeGenerationParams | |
| ): Promise<NarrativeGenerationResult> { | |
| const systemPrompt = `You are a creative storyteller for an interactive video-based choose-your-own-adventure game. | |
| Your role: | |
| 1. Write engaging first-person narrative text that describes what the protagonist sees and experiences | |
| 2. Create a detailed scene description optimized for Sora video generation (cinematic, specific about visuals, camera movement, lighting) | |
| 3. Generate 2-4 meaningful choices that continue the story in interesting directions | |
| Guidelines: | |
| - Keep narratives concise but immersive (2-4 sentences) | |
| - Scene descriptions should be cinematic and specific about visual details | |
| - Choices should be distinct and lead to different narrative paths | |
| - Maintain story coherence and continuity | |
| - Keep content appropriate for general audiences | |
| Return a JSON object with this structure: | |
| { | |
| "narrative": "First-person narrative text shown to the player", | |
| "sceneDescription": "Detailed visual description for Sora video generation", | |
| "choices": [ | |
| {"id": "choice1", "text": "Action the player can take"}, | |
| {"id": "choice2", "text": "Another action the player can take"}, | |
| ... | |
| ] | |
| }`; | |
| let userPrompt = ''; | |
| if (params.isFirstScene) { | |
| userPrompt = `Create the opening scene for a new adventure. The player should start in an intriguing situation with clear choices ahead.`; | |
| } else { | |
| userPrompt = `Story context so far:\n${params.storyContext}\n\n`; | |
| if (params.userChoice) { | |
| userPrompt += `The player chose: ${params.userChoice}\n\n`; | |
| } | |
| userPrompt += `Continue the story from this point. What happens next?`; | |
| } | |
| const response = await fetch(`${API_BASE}/chat/completions`, { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${apiKey}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| model: 'gpt-4o', | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { role: 'user', content: userPrompt } | |
| ], | |
| response_format: { type: 'json_object' }, | |
| temperature: 0.8 | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json().catch(() => ({ error: { message: response.statusText } })); | |
| throw new Error(`Failed to generate narrative: ${error.error?.message || response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| const content = data.choices[0]?.message?.content; | |
| if (!content) { | |
| throw new Error('No narrative generated'); | |
| } | |
| const result = JSON.parse(content); | |
| // Validate and ensure IDs for choices | |
| const choices: StoryChoice[] = (result.choices || []).map((choice: any, index: number) => ({ | |
| id: choice.id || `choice-${Date.now()}-${index}`, | |
| text: choice.text, | |
| description: choice.description | |
| })); | |
| return { | |
| narrative: result.narrative || '', | |
| sceneDescription: result.sceneDescription || result.narrative, | |
| choices | |
| }; | |
| } | |
| /** | |
| * Build a contextual prompt for Sora that includes continuity hints | |
| */ | |
| export function buildSoraPrompt(sceneDescription: string, storyContext?: string): string { | |
| if (!storyContext) { | |
| return sceneDescription; | |
| } | |
| // Add context similarly to sora-extend approach | |
| return `Context (for continuity): | |
| ${storyContext} | |
| Scene: | |
| ${sceneDescription} | |
| The scene should continue smoothly from the previous moment, maintaining consistent visual style, lighting, and subject identity.`; | |
| } | |