298 lines
7.7 KiB
TypeScript
298 lines
7.7 KiB
TypeScript
/**
|
|
* Storyboard Mode Utilities
|
|
*
|
|
* Utility functions for managing storyboard scenes in video generation.
|
|
* Supports duration calculation, validation, and prompt formatting.
|
|
*/
|
|
|
|
import type { StoryboardScene } from '../types/video.types';
|
|
import { generateId } from '@aitu/utils';
|
|
|
|
/**
|
|
* Generate a unique scene ID
|
|
*/
|
|
export function generateSceneId(): string {
|
|
return generateId('scene');
|
|
}
|
|
|
|
/**
|
|
* Create a new empty scene
|
|
*/
|
|
export function createEmptyScene(order: number, duration: number): StoryboardScene {
|
|
return {
|
|
id: generateSceneId(),
|
|
order,
|
|
duration,
|
|
prompt: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Round to 2 decimal places (avoids floating point precision issues)
|
|
* Example: 1.875 -> 1.88, 7.5 -> 7.5, 10 -> 10
|
|
*/
|
|
function roundToTwoDecimals(value: number): number {
|
|
return parseFloat(value.toFixed(2));
|
|
}
|
|
|
|
/**
|
|
* Minimum scene duration constant
|
|
*/
|
|
const MIN_SCENE_DURATION = 0.1;
|
|
|
|
/**
|
|
* Calculate default scene durations using halving strategy
|
|
*
|
|
* Strategy:
|
|
* - 1 scene: total duration
|
|
* - 2 scenes: half each (e.g., 15s -> [7.5, 7.5])
|
|
* - 3+ scenes: first scene gets half, then each new scene halves the last scene's time
|
|
* (e.g., 15s with 3 scenes -> [7.5, 3.75, 3.75])
|
|
*
|
|
* Rules:
|
|
* - Keep up to 2 decimal places
|
|
* - When calculated duration approaches MIN_SCENE_DURATION (0.1s), use MIN_SCENE_DURATION
|
|
* - All subsequent scenes after hitting minimum also get MIN_SCENE_DURATION
|
|
*
|
|
* @param totalDuration Total video duration in seconds
|
|
* @param sceneCount Number of scenes
|
|
* @returns Array of durations for each scene
|
|
*/
|
|
export function calculateDefaultSceneDurations(
|
|
totalDuration: number,
|
|
sceneCount: number
|
|
): number[] {
|
|
if (sceneCount <= 0) return [];
|
|
if (sceneCount === 1) return [totalDuration];
|
|
|
|
const durations: number[] = [];
|
|
let remaining = totalDuration;
|
|
let hitMinimum = false;
|
|
|
|
for (let i = 0; i < sceneCount; i++) {
|
|
if (i === sceneCount - 1) {
|
|
// Last scene gets all remaining time (but not less than minimum)
|
|
const finalDuration = Math.max(MIN_SCENE_DURATION, roundToTwoDecimals(remaining));
|
|
durations.push(finalDuration);
|
|
} else if (hitMinimum) {
|
|
// Once we hit minimum, all subsequent scenes get minimum duration
|
|
durations.push(MIN_SCENE_DURATION);
|
|
remaining -= MIN_SCENE_DURATION;
|
|
} else {
|
|
// Each scene gets half of remaining time
|
|
const duration = remaining / 2;
|
|
const rounded = roundToTwoDecimals(duration);
|
|
|
|
// Check if we're approaching minimum duration
|
|
if (rounded <= MIN_SCENE_DURATION) {
|
|
hitMinimum = true;
|
|
durations.push(MIN_SCENE_DURATION);
|
|
remaining -= MIN_SCENE_DURATION;
|
|
} else {
|
|
durations.push(rounded);
|
|
remaining -= rounded;
|
|
}
|
|
}
|
|
}
|
|
|
|
return durations;
|
|
}
|
|
|
|
/**
|
|
* Calculate equal scene durations
|
|
*
|
|
* Alternative strategy: Distribute time equally among all scenes.
|
|
*
|
|
* @param totalDuration Total video duration in seconds
|
|
* @param sceneCount Number of scenes
|
|
* @returns Array of equal durations
|
|
*/
|
|
export function calculateEqualSceneDurations(
|
|
totalDuration: number,
|
|
sceneCount: number
|
|
): number[] {
|
|
if (sceneCount <= 0) return [];
|
|
const duration = Math.round((totalDuration / sceneCount) * 10) / 10;
|
|
return Array(sceneCount).fill(duration);
|
|
}
|
|
|
|
/**
|
|
* Validate scene durations
|
|
*/
|
|
export interface ValidationResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export function validateSceneDurations(
|
|
scenes: StoryboardScene[],
|
|
totalDuration: number,
|
|
minDuration: number = 1
|
|
): ValidationResult {
|
|
if (scenes.length === 0) {
|
|
return { valid: false, error: '至少需要一个场景' };
|
|
}
|
|
|
|
// Check each scene's duration is within valid range
|
|
for (const scene of scenes) {
|
|
if (scene.duration < minDuration) {
|
|
return {
|
|
valid: false,
|
|
error: `场景 ${scene.order} 时长不能少于 ${minDuration} 秒`,
|
|
};
|
|
}
|
|
if (scene.duration > totalDuration) {
|
|
return {
|
|
valid: false,
|
|
error: `场景 ${scene.order} 时长不能超过 ${totalDuration} 秒`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check for empty prompts
|
|
const emptyPromptScene = scenes.find(s => !s.prompt.trim());
|
|
if (emptyPromptScene) {
|
|
return {
|
|
valid: false,
|
|
error: `场景 ${emptyPromptScene.order} 需要填写提示词`,
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Calculate remaining duration available for new scenes
|
|
*/
|
|
export function calculateRemainingDuration(
|
|
scenes: StoryboardScene[],
|
|
totalDuration: number
|
|
): number {
|
|
const used = scenes.reduce((acc, s) => acc + s.duration, 0);
|
|
return Math.round((totalDuration - used) * 10) / 10;
|
|
}
|
|
|
|
/**
|
|
* Format storyboard scenes into API prompt format
|
|
*
|
|
* Output format:
|
|
* Shot 1:
|
|
* duration: 7.5sec
|
|
* Scene: 飞机起飞
|
|
*
|
|
* Shot 2:
|
|
* duration: 7.5sec
|
|
* Scene: 飞机降落
|
|
*/
|
|
export function formatStoryboardPrompt(scenes: StoryboardScene[]): string {
|
|
return scenes
|
|
.sort((a, b) => a.order - b.order)
|
|
.map(
|
|
(scene, index) =>
|
|
`Shot ${index + 1}:\nduration: ${scene.duration}sec\nScene: ${scene.prompt.trim()}`
|
|
)
|
|
.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Parse storyboard prompt back into scenes (for edit recovery)
|
|
*
|
|
* @param prompt Formatted storyboard prompt
|
|
* @returns Array of scenes or null if not a valid storyboard prompt
|
|
*/
|
|
export function parseStoryboardPrompt(prompt: string): StoryboardScene[] | null {
|
|
const shotRegex =
|
|
/Shot\s+(\d+):\s*\n\s*duration:\s*([\d.]+)\s*sec\s*\n\s*Scene:\s*([^\n]+(?:\n(?!Shot\s+\d+:)[^\n]*)*)/gi;
|
|
|
|
const scenes: StoryboardScene[] = [];
|
|
let match;
|
|
|
|
while ((match = shotRegex.exec(prompt)) !== null) {
|
|
const order = parseInt(match[1], 10);
|
|
const duration = parseFloat(match[2]);
|
|
const scenePrompt = match[3].trim();
|
|
|
|
scenes.push({
|
|
id: generateSceneId(),
|
|
order,
|
|
duration,
|
|
prompt: scenePrompt,
|
|
});
|
|
}
|
|
|
|
if (scenes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Sort by order
|
|
return scenes.sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
/**
|
|
* Check if a prompt is in storyboard format
|
|
*/
|
|
export function isStoryboardPrompt(prompt: string): boolean {
|
|
return /Shot\s+\d+:\s*\n\s*duration:\s*[\d.]+\s*sec/i.test(prompt);
|
|
}
|
|
|
|
/**
|
|
* Redistribute durations when a scene is added or removed
|
|
*
|
|
* @param scenes Current scenes
|
|
* @param totalDuration Total video duration
|
|
* @param addedSceneIndex Index where new scene will be added (-1 for append)
|
|
* @returns Updated scenes with redistributed durations
|
|
*/
|
|
export function redistributeDurationsOnAdd(
|
|
scenes: StoryboardScene[],
|
|
totalDuration: number,
|
|
addedSceneIndex: number = -1
|
|
): StoryboardScene[] {
|
|
const newSceneCount = scenes.length + 1;
|
|
const newDurations = calculateDefaultSceneDurations(totalDuration, newSceneCount);
|
|
|
|
// Create new scene
|
|
const newScene = createEmptyScene(
|
|
addedSceneIndex >= 0 ? addedSceneIndex + 1 : newSceneCount,
|
|
newDurations[newDurations.length - 1]
|
|
);
|
|
|
|
// Insert new scene and update orders
|
|
const updatedScenes = [...scenes];
|
|
if (addedSceneIndex >= 0 && addedSceneIndex < scenes.length) {
|
|
updatedScenes.splice(addedSceneIndex, 0, newScene);
|
|
} else {
|
|
updatedScenes.push(newScene);
|
|
}
|
|
|
|
// Update durations and orders
|
|
return updatedScenes.map((scene, index) => ({
|
|
...scene,
|
|
order: index + 1,
|
|
duration: newDurations[index],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Update scene order numbers after deletion
|
|
*/
|
|
export function reorderScenes(scenes: StoryboardScene[]): StoryboardScene[] {
|
|
return scenes
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((scene, index) => ({
|
|
...scene,
|
|
order: index + 1,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get duration usage percentage for display
|
|
*/
|
|
export function getDurationUsagePercent(
|
|
scenes: StoryboardScene[],
|
|
totalDuration: number
|
|
): number {
|
|
const used = scenes.reduce((acc, s) => acc + s.duration, 0);
|
|
return Math.round((used / totalDuration) * 100);
|
|
}
|