File size: 6,762 Bytes
7ac86fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
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.`;
}