init project
This commit is contained in:
commit
beccc8e652
|
|
@ -0,0 +1,24 @@
|
|||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import './assets/css/main.css';
|
||||
|
||||
/* iOS 風格增強 */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<button
|
||||
@click="handleBack"
|
||||
class="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:class="buttonClass"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'button',
|
||||
variant: 'primary',
|
||||
disabled: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const base = 'px-6 py-3 rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 active:scale-95'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-400 shadow-lg shadow-blue-500/30 disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed',
|
||||
outline: 'border-2 border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-400 disabled:border-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
|
||||
}
|
||||
|
||||
return `${base} ${variants[props.variant]}`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<div :class="cardClass">
|
||||
<div v-if="$slots.header" class="px-6 py-4 border-b border-gray-200 text-gray-900">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="px-6 py-4 text-gray-900">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="px-6 py-4 border-t border-gray-200 bg-gray-50/50 text-gray-900">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
variant?: 'default' | 'elevated'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'default'
|
||||
})
|
||||
|
||||
const cardClass = computed(() => {
|
||||
const base = 'bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden text-gray-900'
|
||||
const variants = {
|
||||
default: `${base} shadow-sm`,
|
||||
elevated: `${base} shadow-2xl shadow-black/5`
|
||||
}
|
||||
return variants[props.variant]
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class="flex flex-col">
|
||||
<label v-if="label" :for="inputId" class="mb-1 text-sm font-medium text-gray-700">
|
||||
{{ label }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
class="px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 disabled:bg-gray-50 disabled:cursor-not-allowed transition-all bg-white/90 backdrop-blur-sm text-gray-900 placeholder:text-gray-400"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue: string
|
||||
type?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
required: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const inputId = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class="flex flex-col">
|
||||
<label v-if="label" :for="textareaId" class="mb-1 text-sm font-medium text-gray-700">
|
||||
{{ label }}
|
||||
</label>
|
||||
<textarea
|
||||
:id="textareaId"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:rows="rows"
|
||||
class="px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 disabled:bg-gray-50 disabled:cursor-not-allowed resize-y transition-all bg-white/90 backdrop-blur-sm text-gray-900 placeholder:text-gray-400"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<span v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
rows?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
required: false,
|
||||
rows: 4
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const textareaId = computed(() => `textarea-${Math.random().toString(36).substr(2, 9)}`)
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
<template>
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="handleClose">
|
||||
<BaseCard variant="elevated" class="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">編輯人物:{{ character?.name }}</h2>
|
||||
<button @click="handleClose" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="character" class="space-y-6">
|
||||
<!-- 基本資訊 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">基本資訊</h3>
|
||||
<div class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="editedCharacter.name"
|
||||
label="角色名稱"
|
||||
:required="true"
|
||||
/>
|
||||
<BaseTextarea
|
||||
v-model="editedCharacter.description"
|
||||
label="角色描述"
|
||||
:rows="3"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="editedCharacter.role"
|
||||
label="角色定位"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外觀設定 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">外觀設定</h3>
|
||||
<div class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="editedCharacter.clothing"
|
||||
label="服裝"
|
||||
placeholder="例如:T恤、牛仔褲"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">配件</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span
|
||||
v-for="(accessory, index) in editedCharacter.accessories"
|
||||
:key="index"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
|
||||
>
|
||||
{{ accessory }}
|
||||
<button
|
||||
@click="removeAccessory(index)"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="newAccessory"
|
||||
placeholder="輸入配件名稱"
|
||||
class="flex-1"
|
||||
@keyup.enter="addAccessory"
|
||||
/>
|
||||
<BaseButton variant="outline" @click="addAccessory">新增</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顏色設定 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">顏色設定</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">髮色</label>
|
||||
<input
|
||||
v-model="editedCharacter.colors.hair"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">膚色</label>
|
||||
<input
|
||||
v-model="editedCharacter.colors.skin"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">服裝顏色</label>
|
||||
<input
|
||||
v-model="editedCharacter.colors.clothing"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 三視圖預覽 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">三視圖</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!editedCharacter.frontView" class="text-gray-400 text-sm">正面</span>
|
||||
<img v-else :src="editedCharacter.frontView" alt="正面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="generateView('front')" class="text-sm px-3 py-1 w-full">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!editedCharacter.sideView" class="text-gray-400 text-sm">側面</span>
|
||||
<img v-else :src="editedCharacter.sideView" alt="側面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="generateView('side')" class="text-sm px-3 py-1 w-full">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!editedCharacter.backView" class="text-gray-400 text-sm">背面</span>
|
||||
<img v-else :src="editedCharacter.backView" alt="背面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="generateView('back')" class="text-sm px-3 py-1 w-full">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按鈕 -->
|
||||
<div class="flex justify-end gap-4 pt-4 border-t border-gray-200">
|
||||
<BaseButton variant="outline" @click="handleClose">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CharacterAsset } from '~/types/storyboard'
|
||||
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
character: CharacterAsset | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [character: CharacterAsset]
|
||||
}>()
|
||||
|
||||
const { generateCharacterView: generateCharacterViewImage, isLoading: isGenerating } = useAIImageGeneration()
|
||||
|
||||
const editedCharacter = ref<CharacterAsset | null>(null)
|
||||
const newAccessory = ref('')
|
||||
const generatingView = ref<'front' | 'side' | 'back' | null>(null)
|
||||
|
||||
watch(() => props.character, (newChar) => {
|
||||
if (newChar) {
|
||||
editedCharacter.value = {
|
||||
...newChar,
|
||||
accessories: [...(newChar.accessories || [])],
|
||||
colors: {
|
||||
hair: newChar.colors?.hair || '#000000',
|
||||
skin: newChar.colors?.skin || '#FFDBAC',
|
||||
clothing: newChar.colors?.clothing || '#000000'
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (editedCharacter.value) {
|
||||
emit('save', editedCharacter.value)
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function addAccessory() {
|
||||
if (newAccessory.value.trim() && editedCharacter.value) {
|
||||
if (!editedCharacter.value.accessories) {
|
||||
editedCharacter.value.accessories = []
|
||||
}
|
||||
editedCharacter.value.accessories.push(newAccessory.value.trim())
|
||||
newAccessory.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeAccessory(index: number) {
|
||||
if (editedCharacter.value?.accessories) {
|
||||
editedCharacter.value.accessories.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateView(view: 'front' | 'side' | 'back') {
|
||||
if (!editedCharacter.value) return
|
||||
|
||||
generatingView.value = view
|
||||
try {
|
||||
const style = '寫實風格' // 可以從 workflow 中取得
|
||||
const imageUrl = await generateCharacterViewImage(
|
||||
editedCharacter.value.name,
|
||||
editedCharacter.value.description,
|
||||
view,
|
||||
style
|
||||
)
|
||||
|
||||
if (view === 'front') {
|
||||
editedCharacter.value.frontView = imageUrl
|
||||
} else if (view === 'side') {
|
||||
editedCharacter.value.sideView = imageUrl
|
||||
} else if (view === 'back') {
|
||||
editedCharacter.value.backView = imageUrl
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('生成視圖失敗:', err)
|
||||
} finally {
|
||||
generatingView.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<template>
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="handleClose">
|
||||
<BaseCard variant="elevated" class="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">編輯場景:{{ scene?.name }}</h2>
|
||||
<button @click="handleClose" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="scene" class="space-y-6">
|
||||
<!-- 基本資訊 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">基本資訊</h3>
|
||||
<div class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="editedScene.name"
|
||||
label="場景名稱"
|
||||
:required="true"
|
||||
/>
|
||||
<BaseTextarea
|
||||
v-model="editedScene.description"
|
||||
label="場景描述"
|
||||
:rows="3"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="editedScene.environment"
|
||||
label="環境設定"
|
||||
placeholder="例如:室內、室外、城市、森林"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 場景設定 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">場景設定</h3>
|
||||
<div class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="editedScene.lighting"
|
||||
label="光線設定"
|
||||
placeholder="例如:自然光、人工光、昏暗"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">道具</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span
|
||||
v-for="(prop, index) in editedScene.props"
|
||||
:key="index"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm"
|
||||
>
|
||||
{{ prop }}
|
||||
<button
|
||||
@click="removeProp(index)"
|
||||
class="text-green-600 hover:text-green-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="newProp"
|
||||
placeholder="輸入道具名稱"
|
||||
class="flex-1"
|
||||
@keyup.enter="addProp"
|
||||
/>
|
||||
<BaseButton variant="outline" @click="addProp">新增</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顏色設定 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">顏色設定</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">主色</label>
|
||||
<input
|
||||
v-model="editedScene.colors.primary"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">次色</label>
|
||||
<input
|
||||
v-model="editedScene.colors.secondary"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700">強調色</label>
|
||||
<input
|
||||
v-model="editedScene.colors.accent"
|
||||
type="color"
|
||||
class="w-full h-10 rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 場景圖片預覽 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-4">場景圖片</h3>
|
||||
<div class="aspect-video bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!editedScene.image" class="text-gray-400">場景圖片</span>
|
||||
<img v-else :src="editedScene.image" alt="場景" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton
|
||||
variant="outline"
|
||||
@click="generateImage"
|
||||
:disabled="isGenerating"
|
||||
class="w-full"
|
||||
>
|
||||
{{ isGenerating ? '生成中...' : '生成場景圖片' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 操作按鈕 -->
|
||||
<div class="flex justify-end gap-4 pt-4 border-t border-gray-200">
|
||||
<BaseButton variant="outline" @click="handleClose">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SceneAsset } from '~/types/storyboard'
|
||||
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
scene: SceneAsset | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [scene: SceneAsset]
|
||||
}>()
|
||||
|
||||
const { generateSceneImage: generateSceneImageUrl, isLoading: isGenerating } = useAIImageGeneration()
|
||||
|
||||
const editedScene = ref<SceneAsset | null>(null)
|
||||
const newProp = ref('')
|
||||
|
||||
watch(() => props.scene, (newScene) => {
|
||||
if (newScene) {
|
||||
editedScene.value = {
|
||||
...newScene,
|
||||
props: [...(newScene.props || [])],
|
||||
colors: {
|
||||
primary: newScene.colors?.primary || '#FFFFFF',
|
||||
secondary: newScene.colors?.secondary || '#CCCCCC',
|
||||
accent: newScene.colors?.accent || '#000000'
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (editedScene.value) {
|
||||
emit('save', editedScene.value)
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function addProp() {
|
||||
if (newProp.value.trim() && editedScene.value) {
|
||||
if (!editedScene.value.props) {
|
||||
editedScene.value.props = []
|
||||
}
|
||||
editedScene.value.props.push(newProp.value.trim())
|
||||
newProp.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeProp(index: number) {
|
||||
if (editedScene.value?.props) {
|
||||
editedScene.value.props.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateImage() {
|
||||
if (!editedScene.value) return
|
||||
|
||||
try {
|
||||
const style = '寫實風格' // 可以從 workflow 中取得
|
||||
const imageUrl = await generateSceneImageUrl(
|
||||
editedScene.value.name,
|
||||
editedScene.value.description,
|
||||
style
|
||||
)
|
||||
editedScene.value.image = imageUrl
|
||||
} catch (err) {
|
||||
console.error('生成場景圖片失敗:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* AI 圖像生成 Composable
|
||||
* 用於生成人物三視圖和場景圖片
|
||||
*/
|
||||
|
||||
import { useAIProvider } from './useAIProvider'
|
||||
import { getImageGenerationModel } from '~/utils/storage'
|
||||
import { DEFAULT_IMAGE_GENERATION_MODEL } from '~/utils/clients/gemini-models'
|
||||
import { getModelProvider } from '~/utils/clients/all-models'
|
||||
|
||||
export function useAIImageGeneration() {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 生成圖像
|
||||
* 注意:目前 Gemini 和 Grok 的圖像生成 API 可能需要不同的實現
|
||||
* 這裡先提供一個基礎框架,實際 API 調用需要根據各 provider 的文檔調整
|
||||
*/
|
||||
async function generateImage(prompt: string): Promise<string> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const modelName = getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL
|
||||
const provider = useAIProvider(modelName)
|
||||
const providerType = getModelProvider(modelName)
|
||||
|
||||
// 根據不同的 provider 使用不同的圖像生成方式
|
||||
// 目前 Gemini 和 Grok 可能沒有直接的圖像生成 API
|
||||
// 這裡使用文字生成模型來生成圖像描述的 prompt,然後由前端處理
|
||||
// 實際應用中,可能需要整合專門的圖像生成服務(如 DALL-E, Midjourney 等)
|
||||
|
||||
// 暫時返回一個提示,說明需要整合圖像生成服務
|
||||
// 實際實作時,這裡應該調用圖像生成 API
|
||||
throw new Error('圖像生成功能需要整合專門的圖像生成服務。目前可以使用文字模型生成圖像描述,然後手動生成圖像。')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
|
||||
error.value = errorMessage
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成人物三視圖
|
||||
*/
|
||||
async function generateCharacterView(
|
||||
characterName: string,
|
||||
characterDescription: string,
|
||||
view: 'front' | 'side' | 'back',
|
||||
style?: string
|
||||
): Promise<string> {
|
||||
const viewNames = {
|
||||
front: '正面',
|
||||
side: '側面',
|
||||
back: '背面'
|
||||
}
|
||||
|
||||
const prompt = `請生成一個${viewNames[view]}視圖的人物圖片,要求:
|
||||
- 角色名稱:${characterName}
|
||||
- 角色描述:${characterDescription}
|
||||
- 視圖角度:${viewNames[view]}
|
||||
- 背景:純白色
|
||||
- 風格:${style || '寫實風格'}
|
||||
- 圖片格式:PNG,透明背景或白色背景
|
||||
- 解析度:512x512 或更高
|
||||
|
||||
請確保圖片清晰,角色特徵明顯。`
|
||||
|
||||
// 目前返回提示文字,實際應該返回圖像 URL 或 base64
|
||||
// 這裡可以整合實際的圖像生成 API
|
||||
return await generateImage(prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成場景圖片
|
||||
*/
|
||||
async function generateSceneImage(
|
||||
sceneName: string,
|
||||
sceneDescription: string,
|
||||
style?: string
|
||||
): Promise<string> {
|
||||
const prompt = `請生成一個場景圖片,要求:
|
||||
- 場景名稱:${sceneName}
|
||||
- 場景描述:${sceneDescription}
|
||||
- 背景:純白色或透明
|
||||
- 風格:${style || '寫實風格'}
|
||||
- 圖片格式:PNG
|
||||
- 解析度:1024x1024 或更高
|
||||
|
||||
請確保場景清晰,細節豐富。`
|
||||
|
||||
return await generateImage(prompt)
|
||||
}
|
||||
|
||||
return {
|
||||
generateImage,
|
||||
generateCharacterView,
|
||||
generateSceneImage,
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* AI Provider Selector Composable
|
||||
* 根據模型名稱自動選擇對應的 provider
|
||||
*/
|
||||
|
||||
import type { AIProvider } from '~/types/ai'
|
||||
import { GeminiClient } from '~/utils/clients/gemini'
|
||||
import { GrokClient } from '~/utils/clients/grok'
|
||||
import { getProviderForModel } from '~/utils/clients/all-models'
|
||||
|
||||
/**
|
||||
* 取得指定模型對應的 AI Provider 實例
|
||||
* @param modelName - 模型名稱,如果不提供則使用預設(Gemini)
|
||||
*/
|
||||
export function useAIProvider(modelName?: string): AIProvider {
|
||||
if (modelName) {
|
||||
const provider = getProviderForModel(modelName)
|
||||
return provider === 'grok' ? new GrokClient() : new GeminiClient()
|
||||
}
|
||||
|
||||
// 預設使用 Gemini
|
||||
return new GeminiClient()
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* AI 分鏡表 Composable
|
||||
* 統一處理錯誤,UI 只呼叫此 composable
|
||||
*/
|
||||
|
||||
import { useAIProvider } from './useAIProvider'
|
||||
import { buildStoryboardPrompt, type StoryboardInput } from '~/utils/ai/prompts'
|
||||
import { getTextModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL as DEFAULT_MODEL } from '~/utils/clients/gemini-models'
|
||||
|
||||
export function useAIStoryboard() {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function generate(input: StoryboardInput): Promise<string> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// 根據選擇的模型自動選擇對應的 provider
|
||||
const modelName = getTextModel() || DEFAULT_MODEL
|
||||
const provider = useAIProvider(modelName)
|
||||
|
||||
const prompt = buildStoryboardPrompt(input)
|
||||
const result = await provider.generateStoryboard(prompt)
|
||||
return result
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
|
||||
error.value = errorMessage
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generate,
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* AI 故事分析 Composable
|
||||
* 用於分析故事並提取資產
|
||||
*/
|
||||
|
||||
import { useAIProvider } from './useAIProvider'
|
||||
import { buildStoryAnalysisPrompt, type StoryAnalysisInput } from '~/utils/ai/prompts'
|
||||
import { getTextModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL } from '~/utils/clients/gemini-models'
|
||||
import { getVisionModel } from '~/utils/storage'
|
||||
import { DEFAULT_VISION_MODEL } from '~/utils/clients/gemini-models'
|
||||
import type { StoryAnalysis } from '~/types/storyboard'
|
||||
|
||||
export function useAIStoryboardAnalysis() {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function analyze(input: StoryAnalysisInput): Promise<StoryAnalysis> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// 如果有圖片,使用視覺模型;否則使用文字模型
|
||||
const modelName = input.styleImage
|
||||
? (getVisionModel() || DEFAULT_VISION_MODEL)
|
||||
: (getTextModel() || DEFAULT_TEXT_MODEL)
|
||||
const provider = useAIProvider(modelName)
|
||||
|
||||
let prompt = buildStoryAnalysisPrompt(input)
|
||||
|
||||
// 如果有風格圖片,需要傳遞圖片給 API
|
||||
if (input.styleImage) {
|
||||
// 這裡需要根據 provider 類型處理圖片
|
||||
// 暫時先傳遞文字 prompt,實際實作時需要處理圖片
|
||||
const result = await provider.analyzeCamera({
|
||||
prompt,
|
||||
image: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: input.styleImage.replace(/^data:image\/\w+;base64,/, '')
|
||||
}
|
||||
})
|
||||
|
||||
// 解析 JSON 結果
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as StoryAnalysis
|
||||
}
|
||||
throw new Error('無法解析分析結果')
|
||||
} else {
|
||||
const result = await provider.generateStoryboard(prompt)
|
||||
|
||||
// 解析 JSON 結果
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as StoryAnalysis
|
||||
}
|
||||
throw new Error('無法解析分析結果')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
|
||||
error.value = errorMessage
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
analyze,
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
<template>
|
||||
<div class="max-w-7xl mx-auto px-8 py-8">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<BaseBackButton />
|
||||
<div class="flex-1 flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900">AI 分鏡表</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-sm text-gray-700">
|
||||
使用模型:<span class="font-medium text-gray-900">{{ currentModelDisplay }}</span>
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="navigateTo('/app/settings')">
|
||||
設定
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步驟指示器 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="flex items-center flex-1"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all',
|
||||
currentStepIndex >= index
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'ml-2 text-sm font-medium',
|
||||
currentStepIndex >= index ? 'text-gray-900' : 'text-gray-500'
|
||||
]"
|
||||
>
|
||||
{{ step.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
:class="[
|
||||
'flex-1 h-0.5 mx-4',
|
||||
currentStepIndex > index ? 'bg-blue-500' : 'bg-gray-200'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步驟 1: 輸入故事 -->
|
||||
<BaseCard v-if="currentStep === 'input'" variant="elevated" class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">輸入故事</h2>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<BaseTextarea
|
||||
v-model="workflow.story"
|
||||
label="故事內容"
|
||||
placeholder="請輸入完整的故事內容..."
|
||||
:rows="10"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-4">
|
||||
<BaseButton variant="primary" @click="handleNextStep" :disabled="!workflow.story.trim()">
|
||||
下一步:選擇風格
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 步驟 2: 選擇風格 -->
|
||||
<BaseCard v-if="currentStep === 'style'" variant="elevated" class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">選擇風格</h2>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- 預設風格選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-900">預設風格</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="preset in stylePresets"
|
||||
:key="preset.id"
|
||||
:class="[
|
||||
'p-4 border-2 rounded-xl cursor-pointer transition-all hover:shadow-lg',
|
||||
workflow.style.preset === preset.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
]"
|
||||
@click="selectStylePreset(preset.id)"
|
||||
>
|
||||
<div class="font-semibold text-gray-900 mb-1">{{ preset.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ preset.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 或上傳圖片分析風格 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-900">或上傳圖片分析風格</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleStyleImageUpload"
|
||||
class="hidden"
|
||||
ref="styleImageInput"
|
||||
/>
|
||||
<div v-if="!workflow.style.customImage" class="space-y-4">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-600">點擊上傳或拖放圖片</p>
|
||||
<BaseButton variant="outline" @click="() => styleImageInput?.click()">
|
||||
選擇圖片
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<img :src="workflow.style.customImage" alt="風格圖片" class="max-h-48 mx-auto rounded-lg" />
|
||||
<BaseButton variant="outline" @click="workflow.style.customImage = ''">
|
||||
重新選擇
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 節奏選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-900">節奏</label>
|
||||
<select
|
||||
v-model="workflow.pace"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<option value="">請選擇節奏</option>
|
||||
<option v-for="pace in paceOptions" :key="pace.id" :value="pace.id">
|
||||
{{ pace.name }} - {{ pace.description }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-4">
|
||||
<BaseButton variant="outline" @click="handlePrevStep">
|
||||
上一步
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="handleAnalyzeStory" :disabled="isAnalyzing">
|
||||
{{ isAnalyzing ? '分析中...' : '分析故事' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 步驟 3: 資產管理 -->
|
||||
<div v-if="currentStep === 'assets'" class="space-y-6">
|
||||
<!-- 人物資產 -->
|
||||
<BaseCard variant="elevated">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">人物資產</h2>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="character in workflow.characters"
|
||||
:key="character.id"
|
||||
class="p-4 border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">{{ character.name }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ character.description }}</p>
|
||||
<span class="inline-block mt-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
|
||||
{{ character.role }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="editCharacter(character.id)" class="text-sm px-3 py-1">
|
||||
編輯
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 三視圖預覽 -->
|
||||
<div class="grid grid-cols-3 gap-4 mt-4">
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!character.frontView" class="text-gray-400 text-sm">正面</span>
|
||||
<img v-else :src="character.frontView" alt="正面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="generateCharacterView(character.id, 'front')" class="text-sm px-3 py-1">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!character.sideView" class="text-gray-400 text-sm">側面</span>
|
||||
<img v-else :src="character.sideView" alt="側面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" size="sm" @click="generateCharacterView(character.id, 'side')">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!character.backView" class="text-gray-400 text-sm">背面</span>
|
||||
<img v-else :src="character.backView" alt="背面" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" size="sm" @click="generateCharacterView(character.id, 'back')">
|
||||
生成
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 場景資產 -->
|
||||
<BaseCard variant="elevated">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">場景資產</h2>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="scene in workflow.scenes"
|
||||
:key="scene.id"
|
||||
class="p-4 border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">{{ scene.name }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ scene.description }}</p>
|
||||
<span class="inline-block mt-2 px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
|
||||
{{ scene.environment }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="editScene(scene.id)" class="text-sm px-3 py-1">
|
||||
編輯
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 場景預覽 -->
|
||||
<div class="mt-4">
|
||||
<div class="aspect-video bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
||||
<span v-if="!scene.image" class="text-gray-400">場景圖片</span>
|
||||
<img v-else :src="scene.image" alt="場景" class="w-full h-full object-contain rounded-lg" />
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="generateSceneImage(scene.id)" class="text-sm px-3 py-1">
|
||||
生成場景圖片
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div class="flex justify-between gap-4">
|
||||
<BaseButton variant="outline" @click="handlePrevStep">
|
||||
上一步
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="handleGenerateStoryboard" :disabled="isGenerating">
|
||||
{{ isGenerating ? '生成中...' : '生成分鏡表' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步驟 4: 分鏡表結果 -->
|
||||
<BaseCard v-if="currentStep === 'result'" variant="elevated">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">分鏡表結果</h2>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="p-4 bg-red-50/80 backdrop-blur-sm border border-red-200 rounded-xl text-red-800 shadow-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="workflow.result" class="space-y-4">
|
||||
<div class="p-4 bg-blue-50 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ workflow.result.title }}</h3>
|
||||
<div class="text-sm text-gray-700">
|
||||
<p><strong>風格:</strong>{{ workflow.result.style }}</p>
|
||||
<p><strong>節奏:</strong>{{ workflow.result.pace }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="shot in workflow.result.shots"
|
||||
:key="shot.shotNumber"
|
||||
class="p-4 border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h4 class="font-semibold text-gray-900">鏡頭 {{ shot.shotNumber }}</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-600"><strong>場景:</strong>{{ shot.scene }}</p>
|
||||
<p v-if="shot.dialogue" class="text-gray-600"><strong>對話:</strong>{{ shot.dialogue }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600"><strong>鏡頭:</strong>{{ shot.camera.shot }}</p>
|
||||
<p class="text-gray-600"><strong>運鏡:</strong>{{ shot.camera.movement }}</p>
|
||||
<p class="text-gray-600"><strong>構圖:</strong>{{ shot.camera.composition }}</p>
|
||||
<p class="text-gray-600"><strong>光線:</strong>{{ shot.camera.lighting }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-700 text-center py-16">
|
||||
請完成前面的步驟以生成分鏡表
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 人物編輯對話框 -->
|
||||
<CharacterEditor
|
||||
:is-open="isCharacterEditorOpen"
|
||||
:character="editingCharacter"
|
||||
@close="isCharacterEditorOpen = false"
|
||||
@save="handleCharacterSave"
|
||||
/>
|
||||
|
||||
<!-- 場景編輯對話框 -->
|
||||
<SceneEditor
|
||||
:is-open="isSceneEditorOpen"
|
||||
:scene="editingScene"
|
||||
@close="isSceneEditorOpen = false"
|
||||
@save="handleSceneSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIStoryboard } from '~/composables/useAIStoryboard'
|
||||
import { useAIStoryboardAnalysis } from '~/composables/useAIStoryboardAnalysis'
|
||||
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
|
||||
import CharacterEditor from '~/components/feature/CharacterEditor.vue'
|
||||
import SceneEditor from '~/components/feature/SceneEditor.vue'
|
||||
import { getTextModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL } from '~/utils/clients/gemini-models'
|
||||
import { getModelInfo } from '~/utils/clients/all-models'
|
||||
import { STYLE_PRESETS } from '~/utils/storyboard/styles'
|
||||
import { PACE_OPTIONS } from '~/utils/storyboard/pace'
|
||||
import type { StoryboardWorkflow, StoryboardStep, CharacterAsset, SceneAsset } from '~/types/storyboard'
|
||||
|
||||
const { generate, isLoading, error } = useAIStoryboard()
|
||||
const { analyze: analyzeStory, isLoading: isAnalyzing, error: analysisError } = useAIStoryboardAnalysis()
|
||||
|
||||
// 工作流程狀態
|
||||
const currentStep = ref<StoryboardStep>('input')
|
||||
const workflow = ref<StoryboardWorkflow>({
|
||||
step: 'input',
|
||||
story: '',
|
||||
style: {},
|
||||
characters: [],
|
||||
scenes: []
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{ id: 'input', name: '輸入故事' },
|
||||
{ id: 'style', name: '選擇風格' },
|
||||
{ id: 'assets', name: '資產管理' },
|
||||
{ id: 'result', name: '分鏡表結果' }
|
||||
]
|
||||
|
||||
const currentStepIndex = computed(() => steps.findIndex(s => s.id === currentStep.value))
|
||||
|
||||
const stylePresets = STYLE_PRESETS
|
||||
const paceOptions = PACE_OPTIONS
|
||||
const styleImageInput = ref<HTMLInputElement>()
|
||||
|
||||
// 顯示目前使用的模型
|
||||
const currentModel = computed(() => getTextModel() || DEFAULT_TEXT_MODEL)
|
||||
const currentModelInfo = computed(() => getModelInfo(currentModel.value))
|
||||
const currentModelDisplay = computed(() => currentModelInfo.value?.displayName || currentModel.value)
|
||||
|
||||
function handleNextStep() {
|
||||
const currentIndex = steps.findIndex(s => s.id === currentStep.value)
|
||||
if (currentIndex >= 0 && currentIndex < steps.length - 1) {
|
||||
const nextStep = steps[currentIndex + 1]
|
||||
if (nextStep) {
|
||||
currentStep.value = nextStep.id as StoryboardStep
|
||||
workflow.value.step = currentStep.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrevStep() {
|
||||
const currentIndex = steps.findIndex(s => s.id === currentStep.value)
|
||||
if (currentIndex > 0) {
|
||||
const prevStep = steps[currentIndex - 1]
|
||||
if (prevStep) {
|
||||
currentStep.value = prevStep.id as StoryboardStep
|
||||
workflow.value.step = currentStep.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectStylePreset(presetId: string) {
|
||||
workflow.value.style.preset = presetId
|
||||
workflow.value.style.customImage = ''
|
||||
}
|
||||
|
||||
function handleStyleImageUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
workflow.value.style.customImage = e.target?.result as string
|
||||
workflow.value.style.preset = undefined
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyzeStory() {
|
||||
if (!workflow.value.story.trim()) return
|
||||
|
||||
try {
|
||||
const styleText = workflow.value.style.preset
|
||||
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
|
||||
: undefined
|
||||
|
||||
const analysis = await analyzeStory({
|
||||
story: workflow.value.story,
|
||||
style: styleText,
|
||||
styleImage: workflow.value.style.customImage
|
||||
})
|
||||
|
||||
workflow.value.analysis = analysis
|
||||
workflow.value.characters = analysis.characters
|
||||
workflow.value.scenes = analysis.scenes
|
||||
|
||||
if (analysis.suggestedStyle && !workflow.value.style.preset) {
|
||||
workflow.value.style.analyzedStyle = analysis.suggestedStyle
|
||||
}
|
||||
|
||||
if (analysis.suggestedPace && !workflow.value.pace) {
|
||||
workflow.value.pace = analysis.suggestedPace
|
||||
}
|
||||
|
||||
handleNextStep()
|
||||
} catch (err) {
|
||||
// 錯誤已由 composable 統一處理
|
||||
}
|
||||
}
|
||||
|
||||
// 編輯對話框狀態
|
||||
const editingCharacter = ref<CharacterAsset | null>(null)
|
||||
const editingScene = ref<SceneAsset | null>(null)
|
||||
const isCharacterEditorOpen = ref(false)
|
||||
const isSceneEditorOpen = ref(false)
|
||||
|
||||
// 圖像生成
|
||||
const { generateCharacterView: generateCharacterViewImage, generateSceneImage: generateSceneImageUrl, isLoading: isGeneratingImage } = useAIImageGeneration()
|
||||
|
||||
function editCharacter(characterId: string) {
|
||||
const character = workflow.value.characters.find(c => c.id === characterId)
|
||||
if (character) {
|
||||
editingCharacter.value = character
|
||||
isCharacterEditorOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleCharacterSave(updatedCharacter: CharacterAsset) {
|
||||
const index = workflow.value.characters.findIndex(c => c.id === updatedCharacter.id)
|
||||
if (index >= 0) {
|
||||
workflow.value.characters[index] = updatedCharacter
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCharacterView(characterId: string, view: 'front' | 'side' | 'back') {
|
||||
const character = workflow.value.characters.find(c => c.id === characterId)
|
||||
if (!character) return
|
||||
|
||||
try {
|
||||
const style = workflow.value.style.preset
|
||||
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
|
||||
: workflow.value.style.analyzedStyle || '寫實風格'
|
||||
|
||||
const imageUrl = await generateCharacterViewImage(
|
||||
character.name,
|
||||
character.description,
|
||||
view,
|
||||
style
|
||||
)
|
||||
|
||||
if (view === 'front') {
|
||||
character.frontView = imageUrl
|
||||
} else if (view === 'side') {
|
||||
character.sideView = imageUrl
|
||||
} else if (view === 'back') {
|
||||
character.backView = imageUrl
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('生成人物視圖失敗:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function editScene(sceneId: string) {
|
||||
const scene = workflow.value.scenes.find(s => s.id === sceneId)
|
||||
if (scene) {
|
||||
editingScene.value = scene
|
||||
isSceneEditorOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleSceneSave(updatedScene: SceneAsset) {
|
||||
const index = workflow.value.scenes.findIndex(s => s.id === updatedScene.id)
|
||||
if (index >= 0) {
|
||||
workflow.value.scenes[index] = updatedScene
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSceneImage(sceneId: string) {
|
||||
const scene = workflow.value.scenes.find(s => s.id === sceneId)
|
||||
if (!scene) return
|
||||
|
||||
try {
|
||||
const style = workflow.value.style.preset
|
||||
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
|
||||
: workflow.value.style.analyzedStyle || '寫實風格'
|
||||
|
||||
const imageUrl = await generateSceneImageUrl(
|
||||
scene.name,
|
||||
scene.description,
|
||||
style
|
||||
)
|
||||
|
||||
scene.image = imageUrl
|
||||
} catch (err) {
|
||||
console.error('生成場景圖片失敗:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = ref(false)
|
||||
|
||||
async function handleGenerateStoryboard() {
|
||||
if (!workflow.value.story.trim()) return
|
||||
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const styleText = workflow.value.style.preset
|
||||
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
|
||||
: (workflow.value.style.analyzedStyle || workflow.value.style.customImage
|
||||
? '根據上傳圖片分析'
|
||||
: undefined)
|
||||
|
||||
const paceText = workflow.value.pace
|
||||
? paceOptions.find(p => p.id === workflow.value.pace)?.name || undefined
|
||||
: undefined
|
||||
|
||||
const resultText = await generate({
|
||||
story: workflow.value.story,
|
||||
style: styleText,
|
||||
pace: paceText,
|
||||
styleImage: workflow.value.style.customImage
|
||||
})
|
||||
|
||||
// 解析 JSON 結果
|
||||
const jsonMatch = resultText.match(/\{[\s\S]*\}/)
|
||||
if (jsonMatch) {
|
||||
workflow.value.result = JSON.parse(jsonMatch[0])
|
||||
handleNextStep()
|
||||
} else {
|
||||
throw new Error('無法解析分鏡表結果')
|
||||
}
|
||||
} catch (err) {
|
||||
// 錯誤已由 composable 統一處理
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div class="max-w-7xl mx-auto px-8 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2 text-gray-900">儀表板</h1>
|
||||
<div class="text-sm text-gray-700 flex flex-wrap items-center gap-2">
|
||||
<span>文字:<span class="font-medium text-gray-900">{{ currentTextModelDisplay }}</span></span>
|
||||
<span class="text-gray-500">|</span>
|
||||
<span>視覺:<span class="font-medium text-gray-900">{{ currentVisionModelDisplay }}</span></span>
|
||||
<span class="text-gray-500">|</span>
|
||||
<span>圖像生成:<span class="font-medium text-gray-900">{{ currentImageGenerationModelDisplay }}</span></span>
|
||||
<span class="text-gray-500">|</span>
|
||||
<span>語音:<span class="font-medium text-gray-900">{{ currentTTSModelDisplay }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton variant="outline" @click="() => router.push('/app/settings')">
|
||||
設定
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="goToStoryboard"
|
||||
>
|
||||
<BaseCard
|
||||
variant="elevated"
|
||||
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900">AI 分鏡表</h3>
|
||||
<p class="text-gray-700 text-sm">
|
||||
輸入故事內容,AI 自動生成專業分鏡表
|
||||
</p>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="goToCamera"
|
||||
>
|
||||
<BaseCard
|
||||
variant="elevated"
|
||||
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900">AI 攝影機</h3>
|
||||
<p class="text-gray-700 text-sm">
|
||||
上傳圖片,AI 分析鏡位與運鏡建議
|
||||
</p>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="goToSoraDirector"
|
||||
>
|
||||
<BaseCard
|
||||
variant="elevated"
|
||||
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900">Sora 導演</h3>
|
||||
<p class="text-gray-700 text-sm">
|
||||
一句話 prompt,AI 生成影片規劃
|
||||
</p>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="goToCutAgent"
|
||||
>
|
||||
<BaseCard
|
||||
variant="elevated"
|
||||
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900">Cut Agent</h3>
|
||||
<p class="text-gray-700 text-sm">
|
||||
素材與劇情,AI 提供剪輯建議
|
||||
</p>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getTextModel, getVisionModel, getImageGenerationModel, getTTSModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, DEFAULT_IMAGE_GENERATION_MODEL, DEFAULT_TTS_MODEL } from '~/utils/clients/gemini-models'
|
||||
import { getModelInfo } from '~/utils/clients/all-models'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 顯示目前使用的模型
|
||||
const currentTextModel = computed(() => getTextModel() || DEFAULT_TEXT_MODEL)
|
||||
const currentVisionModel = computed(() => getVisionModel() || DEFAULT_VISION_MODEL)
|
||||
const currentImageGenerationModel = computed(() => getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL)
|
||||
const currentTTSModel = computed(() => getTTSModel() || DEFAULT_TTS_MODEL)
|
||||
|
||||
const currentTextModelInfo = computed(() => getModelInfo(currentTextModel.value))
|
||||
const currentVisionModelInfo = computed(() => getModelInfo(currentVisionModel.value))
|
||||
const currentImageGenerationModelInfo = computed(() => getModelInfo(currentImageGenerationModel.value))
|
||||
const currentTTSModelInfo = computed(() => getModelInfo(currentTTSModel.value))
|
||||
|
||||
const currentTextModelDisplay = computed(() => currentTextModelInfo.value?.displayName || currentTextModel.value)
|
||||
const currentVisionModelDisplay = computed(() => currentVisionModelInfo.value?.displayName || currentVisionModel.value)
|
||||
const currentImageGenerationModelDisplay = computed(() => currentImageGenerationModelInfo.value?.displayName || currentImageGenerationModel.value)
|
||||
const currentTTSModelDisplay = computed(() => currentTTSModelInfo.value?.displayName || currentTTSModel.value)
|
||||
|
||||
function goToStoryboard() {
|
||||
router.push('/app/ai-storyboard')
|
||||
}
|
||||
|
||||
function goToCamera() {
|
||||
router.push('/app/ai-camera')
|
||||
}
|
||||
|
||||
function goToSoraDirector() {
|
||||
router.push('/app/sora-director')
|
||||
}
|
||||
|
||||
function goToCutAgent() {
|
||||
router.push('/app/cut-agent')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto px-8 py-8">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<BaseBackButton />
|
||||
<h1 class="text-3xl font-bold text-gray-900">設定</h1>
|
||||
</div>
|
||||
|
||||
<BaseCard variant="elevated" class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">API Key 設定</h2>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Gemini Token -->
|
||||
<BaseInput
|
||||
v-model="geminiToken"
|
||||
label="Gemini API Key"
|
||||
type="password"
|
||||
placeholder="請輸入 Gemini API Key"
|
||||
/>
|
||||
|
||||
<!-- Grok Token -->
|
||||
<BaseInput
|
||||
v-model="grokToken"
|
||||
label="Grok API Key (選填)"
|
||||
type="password"
|
||||
placeholder="請輸入 Grok API Key"
|
||||
/>
|
||||
|
||||
<p class="text-sm text-gray-700 -mt-2">
|
||||
提示:系統會根據您選擇的模型自動使用對應的 API Key
|
||||
</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 模型選擇 -->
|
||||
<BaseCard variant="elevated" class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-gray-900">模型選擇</h2>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- 文字模型選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-800">文字生成模型</label>
|
||||
|
||||
<!-- Gemini 模型 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Gemini</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="textModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Gemini 模型">
|
||||
<option v-for="model in geminiTextModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grok 模型 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Grok</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="textModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Grok 模型">
|
||||
<option v-for="model in grokTextModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="currentTextModelInfo" class="mt-3 text-sm text-gray-700">
|
||||
{{ currentTextModelInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 視覺模型選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-800">視覺分析模型</label>
|
||||
|
||||
<!-- Gemini 模型 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Gemini</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="visionModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Gemini 模型">
|
||||
<option v-for="model in geminiVisionModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grok 模型 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Grok</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="visionModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Grok 模型">
|
||||
<option v-for="model in grokVisionModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="currentVisionModelInfo" class="mt-3 text-sm text-gray-700">
|
||||
{{ currentVisionModelInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 圖像生成模型選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-800">圖像生成模型</label>
|
||||
|
||||
<!-- Gemini 模型 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Gemini</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="imageGenerationModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Gemini 模型">
|
||||
<option v-for="model in geminiImageGenerationModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grok 模型 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Grok</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="imageGenerationModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Grok 模型">
|
||||
<option v-for="model in grokImageGenerationModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="currentImageGenerationModelInfo" class="mt-3 text-sm text-gray-700">
|
||||
{{ currentImageGenerationModelInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TTS 模型選擇 -->
|
||||
<div>
|
||||
<label class="block mb-3 font-semibold text-gray-800">語音生成模型 (TTS)</label>
|
||||
|
||||
<!-- Gemini 模型 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Gemini</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="ttsModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Gemini 模型">
|
||||
<option v-for="model in geminiTTSModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grok 模型 -->
|
||||
<div v-if="grokTTSModels.length > 0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
|
||||
<span class="text-sm font-medium text-gray-700">Grok</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="ttsModel"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
|
||||
>
|
||||
<optgroup label="Grok 模型">
|
||||
<option v-for="model in grokTTSModels" :key="model.name" :value="model.name">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="currentTTSModelInfo" class="mt-3 text-sm text-gray-700">
|
||||
{{ currentTTSModelInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 儲存按鈕 -->
|
||||
<div class="flex justify-end gap-4 pt-4">
|
||||
<BaseButton variant="outline" @click="handleCancel">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 成功訊息 -->
|
||||
<div
|
||||
v-if="showSuccess"
|
||||
class="mt-4 p-4 bg-green-100/80 backdrop-blur-sm border border-green-300 rounded-xl text-green-800 shadow-lg"
|
||||
>
|
||||
設定已儲存
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getGeminiToken, setGeminiToken, getGrokToken, setGrokToken, getTextModel, setTextModel, getVisionModel, setVisionModel, getImageGenerationModel, setImageGenerationModel, getTTSModel, setTTSModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, DEFAULT_IMAGE_GENERATION_MODEL, DEFAULT_TTS_MODEL } from '~/utils/clients/gemini-models'
|
||||
import { getModelsByCapability, getModelInfo, getModelsByProvider } from '~/utils/clients/all-models'
|
||||
|
||||
const geminiToken = ref(getGeminiToken() || '')
|
||||
const grokToken = ref(getGrokToken() || '')
|
||||
const textModel = ref(getTextModel() || DEFAULT_TEXT_MODEL)
|
||||
const visionModel = ref(getVisionModel() || DEFAULT_VISION_MODEL)
|
||||
const imageGenerationModel = ref(getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL)
|
||||
const ttsModel = ref(getTTSModel() || DEFAULT_TTS_MODEL)
|
||||
const showSuccess = ref(false)
|
||||
|
||||
// 按 Provider 分組的模型
|
||||
const allTextModels = computed(() => getModelsByCapability('text'))
|
||||
const allVisionModels = computed(() => getModelsByCapability('vision'))
|
||||
const allImageGenerationModels = computed(() => getModelsByCapability('imageGeneration'))
|
||||
const allTTSModels = computed(() => getModelsByCapability('tts'))
|
||||
|
||||
const geminiTextModels = computed(() => allTextModels.value.filter(m => m.provider === 'gemini'))
|
||||
const grokTextModels = computed(() => allTextModels.value.filter(m => m.provider === 'grok'))
|
||||
|
||||
const geminiVisionModels = computed(() => allVisionModels.value.filter(m => m.provider === 'gemini'))
|
||||
const grokVisionModels = computed(() => allVisionModels.value.filter(m => m.provider === 'grok'))
|
||||
|
||||
const geminiImageGenerationModels = computed(() => allImageGenerationModels.value.filter(m => m.provider === 'gemini'))
|
||||
const grokImageGenerationModels = computed(() => allImageGenerationModels.value.filter(m => m.provider === 'grok'))
|
||||
|
||||
const geminiTTSModels = computed(() => allTTSModels.value.filter(m => m.provider === 'gemini'))
|
||||
const grokTTSModels = computed(() => allTTSModels.value.filter(m => m.provider === 'grok'))
|
||||
|
||||
function handleSave() {
|
||||
setGeminiToken(geminiToken.value)
|
||||
setGrokToken(grokToken.value)
|
||||
setTextModel(textModel.value)
|
||||
setVisionModel(visionModel.value)
|
||||
setImageGenerationModel(imageGenerationModel.value)
|
||||
setTTSModel(ttsModel.value)
|
||||
|
||||
showSuccess.value = true
|
||||
setTimeout(() => {
|
||||
showSuccess.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
geminiToken.value = getGeminiToken() || ''
|
||||
grokToken.value = getGrokToken() || ''
|
||||
textModel.value = getTextModel() || DEFAULT_TEXT_MODEL
|
||||
visionModel.value = getVisionModel() || DEFAULT_VISION_MODEL
|
||||
imageGenerationModel.value = getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL
|
||||
ttsModel.value = getTTSModel() || DEFAULT_TTS_MODEL
|
||||
}
|
||||
|
||||
const currentTextModelInfo = computed(() => getModelInfo(textModel.value))
|
||||
const currentVisionModelInfo = computed(() => getModelInfo(visionModel.value))
|
||||
const currentImageGenerationModelInfo = computed(() => getModelInfo(imageGenerationModel.value))
|
||||
const currentTTSModelInfo = computed(() => getModelInfo(ttsModel.value))
|
||||
</script>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="max-w-7xl mx-auto px-8 py-16">
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-5xl font-bold mb-4 text-gray-900">Cut AI</h1>
|
||||
<p class="text-xl text-gray-700 mb-8">
|
||||
一站式 AI 影像製作工具
|
||||
</p>
|
||||
<BaseButton variant="primary" @click="navigateToDashboard">
|
||||
開始使用
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900">AI 分鏡表</h3>
|
||||
<p class="text-gray-700 mb-4">
|
||||
輸入故事內容,AI 自動生成專業分鏡表
|
||||
</p>
|
||||
<BaseButton variant="outline" @click="() => navigateTo('/app/ai-storyboard')">
|
||||
前往
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900">AI 攝影機</h3>
|
||||
<p class="text-gray-700 mb-4">
|
||||
上傳圖片,AI 分析鏡位與運鏡建議
|
||||
</p>
|
||||
<BaseButton variant="outline" @click="() => navigateTo('/app/ai-camera')">
|
||||
前往
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900">Sora 導演</h3>
|
||||
<p class="text-gray-700 mb-4">
|
||||
一句話 prompt,AI 生成影片規劃
|
||||
</p>
|
||||
<BaseButton variant="outline" @click="() => navigateTo('/app/sora-director')">
|
||||
前往
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900">Cut Agent</h3>
|
||||
<p class="text-gray-700 mb-4">
|
||||
素材與劇情,AI 提供剪輯建議
|
||||
</p>
|
||||
<BaseButton variant="outline" @click="() => navigateTo('/app/cut-agent')">
|
||||
前往
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function navigateToDashboard() {
|
||||
return navigateTo('/app/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* AI Provider Interface
|
||||
* 定義所有 AI Provider 必須實作的方法
|
||||
*/
|
||||
export interface AIProvider {
|
||||
/**
|
||||
* 生成分鏡表
|
||||
* @param input - 輸入參數(故事、風格、節奏等)
|
||||
* @returns 分鏡表文字結果
|
||||
*/
|
||||
generateStoryboard(input: unknown): Promise<string>
|
||||
|
||||
/**
|
||||
* 分析攝影機鏡位與運鏡
|
||||
* @param input - 輸入參數(圖片或描述)
|
||||
* @returns 鏡位分析結果
|
||||
*/
|
||||
analyzeCamera(input: unknown): Promise<string>
|
||||
|
||||
/**
|
||||
* 生成影片規劃
|
||||
* @param input - 輸入參數(prompt 或描述)
|
||||
* @returns 影片規劃文字結果
|
||||
*/
|
||||
generateVideoPlan(input: unknown): Promise<string>
|
||||
|
||||
/**
|
||||
* 生成剪輯建議
|
||||
* @param input - 輸入參數(素材、劇情等)
|
||||
* @returns 剪輯建議文字結果
|
||||
*/
|
||||
generateEditSuggestion(input: unknown): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Provider 類型
|
||||
*/
|
||||
export type AIProviderType = 'gemini' | 'grok'
|
||||
|
||||
/**
|
||||
* AI Provider 設定
|
||||
*/
|
||||
export interface AIProviderConfig {
|
||||
type: AIProviderType
|
||||
token: string
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* 鏡頭語言系統類型定義
|
||||
*
|
||||
* 此結構為 AI 知識層,前端不硬編、不限制
|
||||
* 僅結構化呈現 AI 的選擇結果
|
||||
*
|
||||
* 設計為可擴充:透過 Record<string, unknown> 允許動態欄位
|
||||
*/
|
||||
|
||||
/**
|
||||
* 基礎鏡頭語言結構
|
||||
* 包含核心欄位與可擴充的額外欄位
|
||||
*/
|
||||
export interface CameraLanguage {
|
||||
/**
|
||||
* 鏡頭與景別(Shot Size & Lens)
|
||||
* 例如:Extreme Long Shot (XLS), Long Shot (LS), Medium Shot (MS),
|
||||
* Close-Up (CU), Extreme Close-Up (ECU), Over-the-Shoulder (OTS), POV
|
||||
*/
|
||||
shot: string
|
||||
|
||||
/**
|
||||
* 運鏡(Camera Movement)
|
||||
* 例如:Pan, Tilt, Dolly In/Out, Truck, Crane, Handheld, Dolly Zoom
|
||||
*/
|
||||
movement: string
|
||||
|
||||
/**
|
||||
* 構圖(Composition)
|
||||
* 例如:Rule of Thirds, Leading Lines, Depth of Field, Dutch Angle,
|
||||
* Headroom, 180-degree Rule
|
||||
*/
|
||||
composition: string
|
||||
|
||||
/**
|
||||
* 光線(Lighting)
|
||||
* 例如:Three-Point Lighting, High Key, Low Key, Hard Light, Soft Light
|
||||
*/
|
||||
lighting: string
|
||||
|
||||
/**
|
||||
* 描述(Description)
|
||||
* 鏡頭的詳細描述或說明
|
||||
*/
|
||||
description: string
|
||||
|
||||
/**
|
||||
* 可擴充欄位
|
||||
* 允許 AI 或使用者動態添加額外的鏡頭語言資訊
|
||||
* 例如:lens, focalLength, aperture, iso, colorGrading 等
|
||||
*/
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 分鏡表單一鏡頭結構
|
||||
*/
|
||||
export interface StoryboardShot {
|
||||
/**
|
||||
* 鏡頭編號
|
||||
*/
|
||||
shotNumber: number
|
||||
|
||||
/**
|
||||
* 鏡頭語言
|
||||
*/
|
||||
camera: CameraLanguage
|
||||
|
||||
/**
|
||||
* 場景描述
|
||||
*/
|
||||
scene: string
|
||||
|
||||
/**
|
||||
* 對話或旁白
|
||||
*/
|
||||
dialogue?: string
|
||||
|
||||
/**
|
||||
* 時長(秒)
|
||||
*/
|
||||
duration?: number
|
||||
|
||||
/**
|
||||
* 可擴充欄位
|
||||
*/
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整分鏡表結構
|
||||
*/
|
||||
export interface Storyboard {
|
||||
/**
|
||||
* 標題
|
||||
*/
|
||||
title: string
|
||||
|
||||
/**
|
||||
* 風格描述
|
||||
*/
|
||||
style?: string
|
||||
|
||||
/**
|
||||
* 節奏描述
|
||||
*/
|
||||
pace?: string
|
||||
|
||||
/**
|
||||
* 鏡頭列表
|
||||
*/
|
||||
shots: StoryboardShot[]
|
||||
|
||||
/**
|
||||
* 可擴充欄位
|
||||
*/
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* 分鏡表相關類型定義
|
||||
*/
|
||||
|
||||
import type { Storyboard, StoryboardShot } from './camera'
|
||||
|
||||
/**
|
||||
* 預設風格選項
|
||||
*/
|
||||
export interface StylePreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
imageUrl?: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 節奏選項
|
||||
*/
|
||||
export interface PaceOption {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流程步驟
|
||||
*/
|
||||
export type StoryboardStep = 'input' | 'style' | 'assets' | 'characters' | 'scenes' | 'result'
|
||||
|
||||
/**
|
||||
* 故事分析結果
|
||||
*/
|
||||
export interface StoryAnalysis {
|
||||
title: string
|
||||
summary: string
|
||||
characters: CharacterAsset[]
|
||||
scenes: SceneAsset[]
|
||||
suggestedStyle?: string
|
||||
suggestedPace?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 人物資產
|
||||
*/
|
||||
export interface CharacterAsset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
role: string
|
||||
// 三視圖(白色背景)
|
||||
frontView?: string // base64 或 URL
|
||||
sideView?: string
|
||||
backView?: string
|
||||
// 可編輯屬性
|
||||
clothing?: string
|
||||
accessories?: string[]
|
||||
colors?: {
|
||||
hair?: string
|
||||
skin?: string
|
||||
clothing?: string
|
||||
}
|
||||
// 可擴充欄位
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 場景資產
|
||||
*/
|
||||
export interface SceneAsset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
environment: string
|
||||
// 場景圖片(白色背景)
|
||||
image?: string // base64 或 URL
|
||||
// 可編輯屬性
|
||||
lighting?: string
|
||||
props?: string[]
|
||||
colors?: {
|
||||
primary?: string
|
||||
secondary?: string
|
||||
accent?: string
|
||||
}
|
||||
// 可擴充欄位
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 分鏡表工作流程狀態
|
||||
*/
|
||||
export interface StoryboardWorkflow {
|
||||
step: StoryboardStep
|
||||
story: string
|
||||
style: {
|
||||
preset?: string
|
||||
customImage?: string
|
||||
analyzedStyle?: string
|
||||
}
|
||||
pace?: string
|
||||
analysis?: StoryAnalysis
|
||||
characters: CharacterAsset[]
|
||||
scenes: SceneAsset[]
|
||||
result?: Storyboard
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* AI Prompt 組裝工具
|
||||
* 所有 Prompt 組裝邏輯只存在於此
|
||||
*/
|
||||
|
||||
export interface StoryboardInput {
|
||||
story: string
|
||||
style?: string
|
||||
pace?: string
|
||||
styleImage?: string // base64 圖片用於風格分析
|
||||
}
|
||||
|
||||
export interface StoryAnalysisInput {
|
||||
story: string
|
||||
style?: string
|
||||
styleImage?: string
|
||||
}
|
||||
|
||||
export interface AssetGenerationInput {
|
||||
story: string
|
||||
analysis: string
|
||||
characterName?: string
|
||||
sceneName?: string
|
||||
}
|
||||
|
||||
export interface CameraAnalysisInput {
|
||||
image?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface VideoPlanInput {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface EditSuggestionInput {
|
||||
materials: string
|
||||
storyline: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析故事 Prompt
|
||||
*/
|
||||
export function buildStoryAnalysisPrompt(input: StoryAnalysisInput): string {
|
||||
const { story, style, styleImage } = input
|
||||
|
||||
let prompt = `請分析以下故事,提取關鍵資訊。請以 JSON 格式回傳,結構如下:
|
||||
{
|
||||
"title": "故事標題",
|
||||
"summary": "故事摘要",
|
||||
"characters": [
|
||||
{
|
||||
"id": "character_1",
|
||||
"name": "角色名稱",
|
||||
"description": "角色描述",
|
||||
"role": "角色定位(主角、配角等)"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "scene_1",
|
||||
"name": "場景名稱",
|
||||
"description": "場景描述",
|
||||
"environment": "環境設定"
|
||||
}
|
||||
],
|
||||
"suggestedStyle": "建議的視覺風格",
|
||||
"suggestedPace": "建議的節奏"
|
||||
}
|
||||
|
||||
故事內容:
|
||||
${story}`
|
||||
|
||||
if (style) {
|
||||
prompt += `\n\n風格要求:${style}`
|
||||
}
|
||||
|
||||
if (styleImage) {
|
||||
prompt += `\n\n已提供風格參考圖片,請分析圖片中的視覺風格特徵並應用。`
|
||||
}
|
||||
|
||||
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成人物三視圖 Prompt
|
||||
*/
|
||||
export function buildCharacterViewPrompt(input: AssetGenerationInput): string {
|
||||
return `請為角色「${input.characterName || '角色'}」生成白色背景的三視圖(正面、側面、背面)。
|
||||
角色描述:${input.analysis}
|
||||
|
||||
請以 JSON 格式回傳,包含三視圖的描述或生成指令:
|
||||
{
|
||||
"characterName": "角色名稱",
|
||||
"frontView": "正面視圖描述",
|
||||
"sideView": "側面視圖描述",
|
||||
"backView": "背面視圖描述",
|
||||
"description": "角色詳細描述"
|
||||
}
|
||||
|
||||
請確保回傳的是有效的 JSON 格式。`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成場景 Prompt
|
||||
*/
|
||||
export function buildScenePrompt(input: AssetGenerationInput): string {
|
||||
return `請為場景「${input.sceneName || '場景'}」生成白色背景的場景圖。
|
||||
場景描述:${input.analysis}
|
||||
|
||||
請以 JSON 格式回傳:
|
||||
{
|
||||
"sceneName": "場景名稱",
|
||||
"description": "場景詳細描述",
|
||||
"environment": "環境設定",
|
||||
"lighting": "光線建議",
|
||||
"props": ["道具1", "道具2"],
|
||||
"imageDescription": "場景圖片描述"
|
||||
}
|
||||
|
||||
請確保回傳的是有效的 JSON 格式。`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分鏡表 Prompt
|
||||
*/
|
||||
export function buildStoryboardPrompt(input: StoryboardInput): string {
|
||||
const { story, style, pace, styleImage } = input
|
||||
|
||||
let prompt = `請為以下故事生成分鏡表。請以 JSON 格式回傳,結構如下:
|
||||
{
|
||||
"title": "標題",
|
||||
"style": "風格描述",
|
||||
"pace": "節奏描述",
|
||||
"shots": [
|
||||
{
|
||||
"shotNumber": 1,
|
||||
"camera": {
|
||||
"shot": "鏡頭類型(如:Extreme Long Shot, Long Shot, Medium Shot, Close-Up 等)",
|
||||
"movement": "運鏡方式(如:Pan, Tilt, Dolly In/Out, Truck, Crane, Handheld 等)",
|
||||
"composition": "構圖方式(如:Rule of Thirds, Leading Lines, Depth of Field 等)",
|
||||
"lighting": "光線設定(如:Three-Point Lighting, High Key, Low Key 等)",
|
||||
"description": "鏡頭詳細描述"
|
||||
},
|
||||
"scene": "場景描述",
|
||||
"dialogue": "對話或旁白(選填)",
|
||||
"duration": 時長秒數(選填)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
故事內容:
|
||||
${story}`
|
||||
|
||||
if (style) {
|
||||
prompt += `\n\n風格要求:${style}`
|
||||
}
|
||||
|
||||
if (pace) {
|
||||
prompt += `\n\n節奏要求:${pace}`
|
||||
}
|
||||
|
||||
if (styleImage) {
|
||||
prompt += `\n\n已提供風格參考圖片,請參考圖片中的視覺風格。`
|
||||
}
|
||||
|
||||
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成攝影機分析 Prompt
|
||||
*/
|
||||
export function buildCameraAnalysisPrompt(input: CameraAnalysisInput): string {
|
||||
let prompt = `請分析以下影像的攝影機鏡位與運鏡建議。請以 JSON 格式回傳,結構如下:
|
||||
{
|
||||
"camera": {
|
||||
"shot": "建議的鏡頭類型",
|
||||
"movement": "建議的運鏡方式",
|
||||
"composition": "構圖分析",
|
||||
"lighting": "光線分析",
|
||||
"description": "詳細分析與建議"
|
||||
}
|
||||
}`
|
||||
|
||||
if (input.image) {
|
||||
prompt += `\n\n圖片已上傳(base64 或 URL)`
|
||||
}
|
||||
|
||||
if (input.description) {
|
||||
prompt += `\n\n影像描述:${input.description}`
|
||||
}
|
||||
|
||||
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成影片規劃 Prompt
|
||||
*/
|
||||
export function buildVideoPlanPrompt(input: VideoPlanInput): string {
|
||||
return `請為以下 prompt 生成詳細的影片規劃。請以結構化的文字格式回傳,包含:
|
||||
- 影片主題與概念
|
||||
- 分鏡建議
|
||||
- 視覺風格
|
||||
- 節奏與剪輯建議
|
||||
|
||||
Prompt:${input.prompt}
|
||||
|
||||
請提供詳細且實用的規劃建議。`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成剪輯建議 Prompt
|
||||
*/
|
||||
export function buildEditSuggestionPrompt(input: EditSuggestionInput): string {
|
||||
return `請根據以下素材和劇情,提供剪輯建議。請以結構化的文字格式回傳,包含:
|
||||
- 剪輯順序建議
|
||||
- 轉場方式
|
||||
- 節奏控制
|
||||
- 重點鏡頭選擇
|
||||
|
||||
素材描述:${input.materials}
|
||||
|
||||
劇情:${input.storyline}
|
||||
|
||||
請提供詳細且實用的剪輯建議。`
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* 統一的模型列表
|
||||
* 包含 Gemini 和 Grok 所有可用模型
|
||||
*/
|
||||
|
||||
import { GEMINI_MODELS, type GeminiModel } from './gemini-models'
|
||||
import { GROK_MODELS, type GrokModel } from './grok-models'
|
||||
|
||||
export interface UnifiedModel {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
provider: 'gemini' | 'grok'
|
||||
capabilities: {
|
||||
text: boolean
|
||||
vision: boolean
|
||||
audio: boolean
|
||||
video: boolean
|
||||
imageGeneration: boolean
|
||||
tts: boolean
|
||||
}
|
||||
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts' | 'robotics'
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 Gemini 模型轉換為統一格式
|
||||
*/
|
||||
function convertGeminiModel(model: GeminiModel): UnifiedModel {
|
||||
return {
|
||||
name: model.name,
|
||||
displayName: model.displayName,
|
||||
description: model.description,
|
||||
provider: 'gemini',
|
||||
capabilities: model.capabilities,
|
||||
category: model.category
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 Grok 模型轉換為統一格式
|
||||
*/
|
||||
function convertGrokModel(model: GrokModel): UnifiedModel {
|
||||
return {
|
||||
name: model.name,
|
||||
displayName: model.displayName,
|
||||
description: model.description,
|
||||
provider: 'grok',
|
||||
capabilities: model.capabilities,
|
||||
category: model.category
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有可用模型列表(Gemini + Grok)
|
||||
*/
|
||||
export const ALL_MODELS: UnifiedModel[] = [
|
||||
...GEMINI_MODELS.map(convertGeminiModel),
|
||||
...GROK_MODELS.map(convertGrokModel)
|
||||
]
|
||||
|
||||
/**
|
||||
* 根據功能過濾模型
|
||||
*/
|
||||
export function getModelsByCapability(capability: keyof UnifiedModel['capabilities']): UnifiedModel[] {
|
||||
return ALL_MODELS.filter(m => m.capabilities[capability] === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據 Provider 過濾模型
|
||||
*/
|
||||
export function getModelsByProvider(provider: 'gemini' | 'grok'): UnifiedModel[] {
|
||||
return ALL_MODELS.filter(m => m.provider === provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據類別過濾模型
|
||||
*/
|
||||
export function getModelsByCategory(category: UnifiedModel['category']): UnifiedModel[] {
|
||||
return ALL_MODELS.filter(m => m.category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得模型資訊
|
||||
*/
|
||||
export function getModelInfo(modelName: string): UnifiedModel | undefined {
|
||||
return ALL_MODELS.find(m => m.name === modelName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據模型名稱判斷 Provider
|
||||
*/
|
||||
export function getProviderForModel(modelName: string): 'gemini' | 'grok' {
|
||||
const model = getModelInfo(modelName)
|
||||
if (model) {
|
||||
return model.provider
|
||||
}
|
||||
// 如果找不到模型,根據名稱判斷
|
||||
if (modelName.toLowerCase().includes('grok')) {
|
||||
return 'grok'
|
||||
}
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據模型名稱判斷 Provider(別名,為了向後兼容)
|
||||
*/
|
||||
export function getModelProvider(modelName: string): 'gemini' | 'grok' {
|
||||
return getProviderForModel(modelName)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* Gemini API 可用模型列表
|
||||
* 根據 Google Gemini API 官方文檔整理
|
||||
* 參考:https://ai.google.dev/gemini-api/docs?hl=zh-tw
|
||||
*/
|
||||
|
||||
export interface GeminiModel {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
capabilities: {
|
||||
text: boolean
|
||||
vision: boolean
|
||||
audio: boolean
|
||||
video: boolean
|
||||
imageGeneration: boolean
|
||||
tts: boolean
|
||||
}
|
||||
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts' | 'robotics'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini 可用模型列表
|
||||
* 根據 2025 年最新官方文檔
|
||||
*/
|
||||
export const GEMINI_MODELS: GeminiModel[] = [
|
||||
// Gemini 3 系列
|
||||
{
|
||||
name: 'gemini-3-pro',
|
||||
displayName: 'Gemini 3 Pro',
|
||||
description: 'Google 最聰明的模型,全球最出色的多模態理解模型,建立在最先進的推論技術基礎',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// Gemini 2.5 系列
|
||||
{
|
||||
name: 'gemini-2.5-pro',
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
description: 'Google 強大的推理模型,擅長程式設計和複雜的推理工作',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-2.5-pro-tts',
|
||||
displayName: 'Gemini 2.5 Pro TTS',
|
||||
description: 'Gemini 2.5 模型變體,具備原生文字轉語音 (TTS) 功能',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: true
|
||||
},
|
||||
category: 'tts'
|
||||
},
|
||||
{
|
||||
name: 'gemini-2.5-flash',
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
description: '表現最均衡的模型,脈絡窗口達 100 萬個詞元,執行更多工作',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-2.5-flash-lite',
|
||||
displayName: 'Gemini 2.5 Flash-Lite',
|
||||
description: '多模態模型,兼具速度和成本效益,效能優異,適合處理高頻率工作',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-2.5-flash-image',
|
||||
displayName: 'Gemini 2.5 Flash Image',
|
||||
description: '使用原生圖像生成功能,可生成及編輯高度情境化的圖片',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: true,
|
||||
tts: false
|
||||
},
|
||||
category: 'image-generation'
|
||||
},
|
||||
// Gemini 1.5 系列(向後相容)
|
||||
{
|
||||
name: 'gemini-1.5-pro-latest',
|
||||
displayName: 'Gemini 1.5 Pro (Latest)',
|
||||
description: '最新版本的 Gemini 1.5 Pro,支援文字、圖像、音頻、視頻多模態處理',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-1.5-pro',
|
||||
displayName: 'Gemini 1.5 Pro',
|
||||
description: 'Gemini 1.5 Pro,支援文字、圖像、音頻、視頻多模態處理',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-1.5-flash-latest',
|
||||
displayName: 'Gemini 1.5 Flash (Latest)',
|
||||
description: '最新版本的 Gemini 1.5 Flash,輕量級快速響應,支援多模態',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'gemini-1.5-flash',
|
||||
displayName: 'Gemini 1.5 Flash',
|
||||
description: 'Gemini 1.5 Flash,輕量級快速響應,支援多模態',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: true,
|
||||
video: true,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// 其他模型
|
||||
{
|
||||
name: 'gemini-pro',
|
||||
displayName: 'Gemini Pro',
|
||||
description: 'Gemini Pro,支援文字和圖像處理',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// 機器人模型
|
||||
{
|
||||
name: 'gemini-robotics-er-1.5',
|
||||
displayName: 'Gemini Robotics-ER 1.5',
|
||||
description: '視覺語言模型 (VLM),可將 Gemini 的代理功能帶入機器人領域,在實體世界中進行進階推理',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'robotics'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 預設模型(用於文字生成)
|
||||
*/
|
||||
export const DEFAULT_TEXT_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
/**
|
||||
* 預設視覺模型(用於圖像分析)
|
||||
*/
|
||||
export const DEFAULT_VISION_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
/**
|
||||
* 預設音頻模型(用於音頻處理)
|
||||
*/
|
||||
export const DEFAULT_AUDIO_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
/**
|
||||
* 預設圖像生成模型
|
||||
*/
|
||||
export const DEFAULT_IMAGE_GENERATION_MODEL = 'gemini-2.5-flash-image'
|
||||
|
||||
/**
|
||||
* 預設 TTS 模型(文字轉語音)
|
||||
*/
|
||||
export const DEFAULT_TTS_MODEL = 'gemini-2.5-pro-tts'
|
||||
|
||||
/**
|
||||
* 根據功能需求取得適合的模型
|
||||
*/
|
||||
export function getModelForTask(
|
||||
task: 'text' | 'vision' | 'audio' | 'video' | 'image-generation' | 'tts'
|
||||
): string {
|
||||
switch (task) {
|
||||
case 'text':
|
||||
return DEFAULT_TEXT_MODEL
|
||||
case 'vision':
|
||||
return DEFAULT_VISION_MODEL
|
||||
case 'audio':
|
||||
return DEFAULT_AUDIO_MODEL
|
||||
case 'video':
|
||||
return DEFAULT_VISION_MODEL // 視頻使用視覺模型
|
||||
case 'image-generation':
|
||||
return DEFAULT_IMAGE_GENERATION_MODEL
|
||||
case 'tts':
|
||||
return DEFAULT_TTS_MODEL
|
||||
default:
|
||||
return DEFAULT_TEXT_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據類別取得模型列表
|
||||
*/
|
||||
export function getModelsByCategory(category: GeminiModel['category']): GeminiModel[] {
|
||||
return GEMINI_MODELS.filter(model => model.category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得支援特定功能的模型列表
|
||||
*/
|
||||
export function getModelsByCapability(capability: keyof GeminiModel['capabilities']): GeminiModel[] {
|
||||
return GEMINI_MODELS.filter(model => model.capabilities[capability] === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得模型資訊
|
||||
*/
|
||||
export function getModelInfo(modelName: string): GeminiModel | undefined {
|
||||
return GEMINI_MODELS.find(m => m.name === modelName)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Gemini Client
|
||||
* 單一責任:呼叫 Gemini API
|
||||
* 從 localStorage 讀取 token
|
||||
*/
|
||||
|
||||
import type { AIProvider } from '~/types/ai'
|
||||
import { getGeminiToken, getTextModel, getVisionModel } from '~/utils/storage'
|
||||
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, getModelForTask } from './gemini-models'
|
||||
|
||||
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta'
|
||||
|
||||
/**
|
||||
* Gemini API Client
|
||||
*/
|
||||
export class GeminiClient implements AIProvider {
|
||||
private token: string | null
|
||||
|
||||
constructor() {
|
||||
this.token = getGeminiToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 API Token
|
||||
*/
|
||||
private getToken(): string {
|
||||
const token = getGeminiToken()
|
||||
if (!token) {
|
||||
throw new Error('Gemini API Token 未設定,請至設定頁面輸入')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫 Gemini API
|
||||
* @param prompt - 文字提示
|
||||
* @param model - 模型名稱,預設使用儲存的模型或 DEFAULT_TEXT_MODEL
|
||||
* @param parts - 多模態內容(圖像、音頻等)
|
||||
*/
|
||||
private async callAPI(
|
||||
prompt: string,
|
||||
model?: string,
|
||||
parts?: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }>
|
||||
): Promise<string> {
|
||||
// 如果沒有指定模型,使用儲存的模型或預設模型
|
||||
const selectedModel = model || getTextModel() || DEFAULT_TEXT_MODEL
|
||||
const token = this.getToken()
|
||||
const url = `${GEMINI_API_BASE}/models/${selectedModel}:generateContent?key=${token}`
|
||||
|
||||
// 組裝 parts
|
||||
const contentParts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = []
|
||||
|
||||
if (prompt) {
|
||||
contentParts.push({ text: prompt })
|
||||
}
|
||||
|
||||
if (parts) {
|
||||
contentParts.push(...parts)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{
|
||||
parts: contentParts
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分鏡表
|
||||
*/
|
||||
async generateStoryboard(input: unknown): Promise<string> {
|
||||
// Prompt 組裝將由 utils/ai 處理,這裡只負責呼叫 API
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析攝影機鏡位與運鏡
|
||||
* 支援圖像輸入(base64 或 URL)
|
||||
*/
|
||||
async analyzeCamera(input: unknown): Promise<string> {
|
||||
const model = getVisionModel() || DEFAULT_VISION_MODEL
|
||||
|
||||
// 如果 input 是物件且包含圖像
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
const inputObj = input as { prompt?: string; image?: { mimeType: string; data: string } }
|
||||
const prompt = inputObj.prompt || '請分析這張圖片的攝影機鏡位與運鏡建議'
|
||||
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = []
|
||||
|
||||
if (inputObj.image) {
|
||||
parts.push({ inlineData: inputObj.image })
|
||||
}
|
||||
|
||||
return this.callAPI(prompt, model, parts)
|
||||
}
|
||||
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成影片規劃
|
||||
*/
|
||||
async generateVideoPlan(input: unknown): Promise<string> {
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成剪輯建議
|
||||
*/
|
||||
async generateEditSuggestion(input: unknown): Promise<string> {
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Grok API 可用模型列表
|
||||
* 根據 x.ai 控制台整理
|
||||
*/
|
||||
|
||||
export interface GrokModel {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
capabilities: {
|
||||
text: boolean
|
||||
vision: boolean
|
||||
audio: boolean
|
||||
video: boolean
|
||||
imageGeneration: boolean
|
||||
tts: boolean
|
||||
}
|
||||
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts'
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok 可用模型列表
|
||||
* 根據 x.ai 控制台整理
|
||||
*/
|
||||
export const GROK_MODELS: GrokModel[] = [
|
||||
// Grok 4.1 系列
|
||||
{
|
||||
name: 'grok-4-1-fast-reasoning',
|
||||
displayName: 'Grok 4.1 Fast Reasoning',
|
||||
description: 'Grok 4.1 Fast Reasoning 模型,支援文字和圖像,具備推理能力,Context: 2,000,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'grok-4-1-fast-non-reasoning',
|
||||
displayName: 'Grok 4.1 Fast Non-Reasoning',
|
||||
description: 'Grok 4.1 Fast Non-Reasoning 模型,支援文字和圖像,Context: 2,000,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// Grok 4 系列
|
||||
{
|
||||
name: 'grok-4-fast-reasoning',
|
||||
displayName: 'Grok 4 Fast Reasoning',
|
||||
description: 'Grok 4 Fast Reasoning 模型,支援文字和圖像,具備推理能力,Context: 2,000,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'grok-4-fast-non-reasoning',
|
||||
displayName: 'Grok 4 Fast Non-Reasoning',
|
||||
description: 'Grok 4 Fast Non-Reasoning 模型,支援文字和圖像,Context: 2,000,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'grok-4-0709',
|
||||
displayName: 'Grok 4 (0709)',
|
||||
description: 'Grok 4 模型,支援文字和圖像,Context: 256,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// Grok 3 系列
|
||||
{
|
||||
name: 'grok-3',
|
||||
displayName: 'Grok 3',
|
||||
description: 'Grok 3 模型,支援文字和圖像,Context: 131,072',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
{
|
||||
name: 'grok-3-mini',
|
||||
displayName: 'Grok 3 Mini',
|
||||
description: 'Grok 3 Mini 模型,支援文字和圖像,Context: 131,072',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// Grok Code 系列
|
||||
{
|
||||
name: 'grok-code-fast-1',
|
||||
displayName: 'Grok Code Fast 1',
|
||||
description: 'Grok Code Fast 1 模型,專為程式碼優化,支援文字和圖像,Context: 256,000',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'multimodal'
|
||||
},
|
||||
// Grok 2 系列
|
||||
{
|
||||
name: 'grok-2-vision-1212',
|
||||
displayName: 'Grok 2 Vision (1212)',
|
||||
description: 'Grok 2 Vision 模型,支援文字和圖像理解,Context: 32,768',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: false,
|
||||
tts: false
|
||||
},
|
||||
category: 'vision'
|
||||
},
|
||||
// 圖像生成模型
|
||||
{
|
||||
name: 'grok-2-image-1212',
|
||||
displayName: 'Grok 2 Image (1212)',
|
||||
description: 'Grok 2 Image 模型,支援圖像生成,支援文字和圖像輸入',
|
||||
capabilities: {
|
||||
text: true,
|
||||
vision: true,
|
||||
audio: false,
|
||||
video: false,
|
||||
imageGeneration: true,
|
||||
tts: false
|
||||
},
|
||||
category: 'image-generation'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 預設文字模型
|
||||
*/
|
||||
export const DEFAULT_GROK_TEXT_MODEL = 'grok-4-1-fast-reasoning'
|
||||
|
||||
/**
|
||||
* 預設視覺模型
|
||||
*/
|
||||
export const DEFAULT_GROK_VISION_MODEL = 'grok-4-1-fast-reasoning'
|
||||
|
||||
/**
|
||||
* 預設圖像生成模型
|
||||
*/
|
||||
export const DEFAULT_GROK_IMAGE_GENERATION_MODEL = 'grok-2-image-1212'
|
||||
|
||||
/**
|
||||
* 取得模型資訊
|
||||
*/
|
||||
export function getGrokModelInfo(modelName: string): GrokModel | undefined {
|
||||
return GROK_MODELS.find(m => m.name === modelName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據功能需求取得適合的 Grok 模型
|
||||
*/
|
||||
export function getGrokModelForTask(
|
||||
task: 'text' | 'vision' | 'audio' | 'video' | 'image-generation' | 'tts'
|
||||
): string {
|
||||
switch (task) {
|
||||
case 'text':
|
||||
return DEFAULT_GROK_TEXT_MODEL
|
||||
case 'vision':
|
||||
return DEFAULT_GROK_VISION_MODEL
|
||||
case 'image-generation':
|
||||
return DEFAULT_GROK_IMAGE_GENERATION_MODEL
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'tts':
|
||||
default:
|
||||
return DEFAULT_GROK_TEXT_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Grok Client
|
||||
* 與 Gemini 相同介面
|
||||
* 預設不啟用
|
||||
*/
|
||||
|
||||
import type { AIProvider } from '~/types/ai'
|
||||
import { getGrokToken, getTextModel, getVisionModel } from '~/utils/storage'
|
||||
import { DEFAULT_GROK_TEXT_MODEL, DEFAULT_GROK_VISION_MODEL, getGrokModelForTask } from './grok-models'
|
||||
|
||||
const GROK_API_BASE = 'https://api.x.ai/v1'
|
||||
|
||||
/**
|
||||
* Grok API Client
|
||||
*/
|
||||
export class GrokClient implements AIProvider {
|
||||
/**
|
||||
* 取得 API Token
|
||||
*/
|
||||
private getToken(): string {
|
||||
const token = getGrokToken()
|
||||
if (!token) {
|
||||
throw new Error('Grok API Token 未設定,請至設定頁面輸入')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫 Grok API
|
||||
* @param prompt - 文字提示
|
||||
* @param model - 模型名稱,預設使用 grok-2
|
||||
* @param parts - 多模態內容(圖像等)
|
||||
*/
|
||||
private async callAPI(
|
||||
prompt: string,
|
||||
model: string = DEFAULT_GROK_TEXT_MODEL,
|
||||
parts?: Array<{ text?: string; image_url?: { url: string } }>
|
||||
): Promise<string> {
|
||||
const token = this.getToken()
|
||||
const url = `${GROK_API_BASE}/chat/completions`
|
||||
|
||||
// 組裝 messages
|
||||
const messages: Array<{ role: string; content: Array<{ type: string; text?: string; image_url?: { url: string } }> }> = []
|
||||
|
||||
const contentParts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
|
||||
|
||||
if (prompt) {
|
||||
contentParts.push({ type: 'text', text: prompt })
|
||||
}
|
||||
|
||||
if (parts) {
|
||||
parts.forEach(part => {
|
||||
if (part.image_url) {
|
||||
contentParts.push({ type: 'image_url', image_url: part.image_url })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: contentParts
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.choices?.[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分鏡表
|
||||
*/
|
||||
async generateStoryboard(input: unknown): Promise<string> {
|
||||
// 如果選擇的模型是 Grok 模型,使用選擇的模型;否則使用預設
|
||||
const selectedModel = getTextModel()
|
||||
const model = selectedModel && selectedModel.includes('grok')
|
||||
? selectedModel
|
||||
: getGrokModelForTask('text')
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析攝影機鏡位與運鏡
|
||||
* 支援圖像輸入
|
||||
*/
|
||||
async analyzeCamera(input: unknown): Promise<string> {
|
||||
// 如果選擇的模型是 Grok 模型,使用選擇的模型;否則使用預設
|
||||
const selectedModel = getVisionModel()
|
||||
const model = selectedModel && selectedModel.includes('grok')
|
||||
? selectedModel
|
||||
: getGrokModelForTask('vision')
|
||||
|
||||
// 如果 input 是物件且包含圖像
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
const inputObj = input as { prompt?: string; image?: { url: string } }
|
||||
const prompt = inputObj.prompt || '請分析這張圖片的攝影機鏡位與運鏡建議'
|
||||
const parts: Array<{ text?: string; image_url?: { url: string } }> = []
|
||||
|
||||
if (inputObj.image) {
|
||||
parts.push({ image_url: inputObj.image })
|
||||
}
|
||||
|
||||
return this.callAPI(prompt, model, parts)
|
||||
}
|
||||
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成影片規劃
|
||||
*/
|
||||
async generateVideoPlan(input: unknown): Promise<string> {
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成剪輯建議
|
||||
*/
|
||||
async generateEditSuggestion(input: unknown): Promise<string> {
|
||||
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
|
||||
return this.callAPI(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* 列出可用的 Gemini 模型
|
||||
* 呼叫 Google API 取得實際可用的模型列表
|
||||
*/
|
||||
|
||||
import { getGeminiToken } from '~/utils/storage'
|
||||
|
||||
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1'
|
||||
|
||||
/**
|
||||
* 取得所有可用的模型列表
|
||||
*/
|
||||
export async function listAvailableModels(): Promise<Array<{
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
supportedGenerationMethods: string[]
|
||||
}>> {
|
||||
const token = getGeminiToken()
|
||||
if (!token) {
|
||||
throw new Error('Gemini API Token 未設定,請至設定頁面輸入')
|
||||
}
|
||||
|
||||
const url = `${GEMINI_API_BASE}/models?key=${token}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 過濾出支援 generateContent 的模型
|
||||
const models = (data.models || [])
|
||||
.filter((model: any) =>
|
||||
model.supportedGenerationMethods?.includes('generateContent')
|
||||
)
|
||||
.map((model: any) => ({
|
||||
name: model.name.replace('models/', ''),
|
||||
displayName: model.displayName || model.name,
|
||||
description: model.description || '',
|
||||
supportedGenerationMethods: model.supportedGenerationMethods || []
|
||||
}))
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
console.error('取得模型列表失敗:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* localStorage 工具函數
|
||||
* 用於管理 AI Token 和 Provider 設定
|
||||
*/
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
GEMINI_TOKEN: 'ai_token_gemini',
|
||||
GROK_TOKEN: 'ai_token_grok',
|
||||
PROVIDER: 'ai_provider',
|
||||
TEXT_MODEL: 'ai_text_model',
|
||||
VISION_MODEL: 'ai_vision_model',
|
||||
IMAGE_GENERATION_MODEL: 'ai_image_generation_model',
|
||||
TTS_MODEL: 'ai_tts_model'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 取得 Gemini Token
|
||||
*/
|
||||
export function getGeminiToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.GEMINI_TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定 Gemini Token
|
||||
*/
|
||||
export function setGeminiToken(token: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.GEMINI_TOKEN, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 Grok Token
|
||||
*/
|
||||
export function getGrokToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.GROK_TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定 Grok Token
|
||||
*/
|
||||
export function setGrokToken(token: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.GROK_TOKEN, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前 Provider
|
||||
*/
|
||||
export function getProvider(): 'gemini' | 'grok' {
|
||||
if (typeof window === 'undefined') return 'gemini'
|
||||
const provider = localStorage.getItem(STORAGE_KEYS.PROVIDER)
|
||||
return (provider === 'grok' ? 'grok' : 'gemini') as 'gemini' | 'grok'
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定 Provider
|
||||
*/
|
||||
export function setProvider(provider: 'gemini' | 'grok'): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.PROVIDER, provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前 Provider 的 Token
|
||||
*/
|
||||
export function getCurrentProviderToken(): string | null {
|
||||
const provider = getProvider()
|
||||
return provider === 'gemini' ? getGeminiToken() : getGrokToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得文字模型
|
||||
*/
|
||||
export function getTextModel(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.TEXT_MODEL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定文字模型
|
||||
*/
|
||||
export function setTextModel(model: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.TEXT_MODEL, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得視覺模型
|
||||
*/
|
||||
export function getVisionModel(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.VISION_MODEL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定視覺模型
|
||||
*/
|
||||
export function setVisionModel(model: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.VISION_MODEL, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得圖像生成模型
|
||||
*/
|
||||
export function getImageGenerationModel(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.IMAGE_GENERATION_MODEL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定圖像生成模型
|
||||
*/
|
||||
export function setImageGenerationModel(model: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.IMAGE_GENERATION_MODEL, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 TTS 模型
|
||||
*/
|
||||
export function getTTSModel(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEYS.TTS_MODEL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定 TTS 模型
|
||||
*/
|
||||
export function setTTSModel(model: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEYS.TTS_MODEL, model)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 節奏選項
|
||||
*/
|
||||
|
||||
import type { PaceOption } from '~/types/storyboard'
|
||||
|
||||
/**
|
||||
* 節奏選項列表
|
||||
*/
|
||||
export const PACE_OPTIONS: PaceOption[] = [
|
||||
{
|
||||
id: 'very-slow',
|
||||
name: '非常緩慢',
|
||||
description: '靜態鏡頭為主,長時間停留,適合情感表達'
|
||||
},
|
||||
{
|
||||
id: 'slow',
|
||||
name: '緩慢',
|
||||
description: '平穩節奏,從容不迫,適合敘事'
|
||||
},
|
||||
{
|
||||
id: 'moderate',
|
||||
name: '中等',
|
||||
description: '平衡節奏,標準剪輯速度'
|
||||
},
|
||||
{
|
||||
id: 'fast',
|
||||
name: '快速',
|
||||
description: '快速剪輯,動態感強,適合動作場景'
|
||||
},
|
||||
{
|
||||
id: 'very-fast',
|
||||
name: '非常快速',
|
||||
description: '極速剪輯,緊張刺激,適合高潮場景'
|
||||
},
|
||||
{
|
||||
id: 'variable',
|
||||
name: '變化',
|
||||
description: '根據劇情需要動態調整節奏'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 取得節奏選項
|
||||
*/
|
||||
export function getPaceOption(id: string): PaceOption | undefined {
|
||||
return PACE_OPTIONS.find(p => p.id === id)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* 風格預設選項
|
||||
*/
|
||||
|
||||
import type { StylePreset } from '~/types/storyboard'
|
||||
|
||||
/**
|
||||
* 預設風格列表
|
||||
*/
|
||||
export const STYLE_PRESETS: StylePreset[] = [
|
||||
{
|
||||
id: 'simpsons',
|
||||
name: '辛普森家庭',
|
||||
description: '美式卡通風格,鮮豔色彩,簡化線條',
|
||||
keywords: ['cartoon', 'yellow', 'simplified', 'vibrant']
|
||||
},
|
||||
{
|
||||
id: 'anime',
|
||||
name: '日式動漫',
|
||||
description: '日式動畫風格,大眼睛,精緻細節',
|
||||
keywords: ['anime', 'manga', 'detailed', 'expressive']
|
||||
},
|
||||
{
|
||||
id: 'pixar',
|
||||
name: '皮克斯',
|
||||
description: '3D 動畫風格,寫實光影,豐富細節',
|
||||
keywords: ['3d', 'realistic', 'lighting', 'detailed']
|
||||
},
|
||||
{
|
||||
id: 'minimalist',
|
||||
name: '極簡風格',
|
||||
description: '簡約線條,少數色彩,乾淨設計',
|
||||
keywords: ['minimal', 'clean', 'simple', 'geometric']
|
||||
},
|
||||
{
|
||||
id: 'noir',
|
||||
name: '黑色電影',
|
||||
description: '高對比黑白,戲劇性光影,復古氛圍',
|
||||
keywords: ['black', 'white', 'dramatic', 'vintage']
|
||||
},
|
||||
{
|
||||
id: 'cyberpunk',
|
||||
name: '賽博龐克',
|
||||
description: '霓虹色彩,未來科技,都市夜景',
|
||||
keywords: ['neon', 'futuristic', 'tech', 'urban']
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
name: '水彩風格',
|
||||
description: '柔和色彩,流動筆觸,藝術感',
|
||||
keywords: ['watercolor', 'soft', 'artistic', 'flowing']
|
||||
},
|
||||
{
|
||||
id: 'realistic',
|
||||
name: '寫實風格',
|
||||
description: '真實光影,細節豐富,電影級質感',
|
||||
keywords: ['realistic', 'photorealistic', 'cinematic', 'detailed']
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 取得風格預設
|
||||
*/
|
||||
export function getStylePreset(id: string): StylePreset | undefined {
|
||||
return STYLE_PRESETS.find(s => s.id === id)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# Cut AI — Frontend Specification (Nuxt 4 · Gemini-First)
|
||||
|
||||
## 1. 專案定位
|
||||
Cut AI 是一個「一站式 AI 影像製作工具」的前端應用。
|
||||
本專案以 **真實 AI(Gemini)即時產出** 為核心,不使用 mock data。
|
||||
|
||||
核心原則:
|
||||
- 真實 AI 結果優先
|
||||
- 架構穩定、可長期演進
|
||||
- 前端作為 AI Orchestrator,而非單純 UI
|
||||
|
||||
---
|
||||
|
||||
## 2. 既有技術基底(已鎖定)
|
||||
|
||||
```json
|
||||
{
|
||||
"nuxt": "^4.2.2",
|
||||
"vue": "^3.5.x",
|
||||
"vue-router": "^4.x"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency 使用政策(兩階段制)
|
||||
|
||||
### 3.1 規劃階段(Planning Phase)
|
||||
- 允許「討論」是否需要新增 dependency
|
||||
- 僅限提出建議與理由
|
||||
- **不得實際安裝或引入**
|
||||
|
||||
### 3.2 實作階段(Execution Phase)
|
||||
- ❌ 禁止新增任何 dependency
|
||||
- ❌ 禁止隱性引入(UI library / helper library)
|
||||
- ✅ 僅使用既有 dependency 與原生能力
|
||||
|
||||
> 一旦進入實作階段,dependency 清單視為「鎖定狀態」。
|
||||
|
||||
---
|
||||
|
||||
## 4. AI Provider 策略
|
||||
|
||||
### Primary
|
||||
- **Gemini API**
|
||||
- 預設唯一啟用
|
||||
|
||||
### Secondary(預留)
|
||||
- Grok API
|
||||
- 僅保留 client 與切換能力,不預設啟用
|
||||
|
||||
---
|
||||
|
||||
## 5. Token 管理(前端)
|
||||
|
||||
- Token 由使用者於設定頁手動輸入
|
||||
- 儲存於 localStorage
|
||||
- `ai_token_gemini`
|
||||
- `ai_token_grok`
|
||||
- 不 hardcode token
|
||||
- 不驗證 token 正確性(由 API 回傳錯誤)
|
||||
|
||||
---
|
||||
|
||||
## 6. 專案目錄結構(建議)
|
||||
|
||||
```
|
||||
/components
|
||||
/base
|
||||
/cards
|
||||
/layout
|
||||
|
||||
/pages
|
||||
index.vue
|
||||
/app
|
||||
dashboard.vue
|
||||
ai-storyboard.vue
|
||||
ai-camera.vue
|
||||
sora-director.vue
|
||||
cut-agent.vue
|
||||
settings.vue
|
||||
|
||||
/composables
|
||||
/utils
|
||||
/types
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 鏡頭語言系統(Camera Language System)
|
||||
|
||||
鏡頭語言為 **AI 知識層**,非 UI 邏輯。
|
||||
前端不硬編、不限制,僅結構化呈現 Gemini 的選擇。
|
||||
|
||||
### 7.1 鏡頭與景別(Shot Size & Lens)
|
||||
- Extreme Long Shot (XLS / ELS)
|
||||
- Long Shot (LS)
|
||||
- Medium Shot (MS)
|
||||
- Close-Up (CU)
|
||||
- Extreme Close-Up (ECU)
|
||||
- Over-the-Shoulder Shot (OTS)
|
||||
- Point of View (POV)
|
||||
|
||||
### 7.2 運鏡(Camera Movement)
|
||||
- Pan
|
||||
- Tilt
|
||||
- Dolly In / Dolly Out
|
||||
- Truck / Tracking Shot
|
||||
- Crane / Boom
|
||||
- Handheld
|
||||
- Dolly Zoom / Vertigo Effect
|
||||
|
||||
### 7.3 分鏡構圖(Composition & Storyboarding)
|
||||
- Rule of Thirds
|
||||
- Leading Lines
|
||||
- Depth of Field(Shallow / Deep)
|
||||
- Dutch Angle / Canted Angle
|
||||
- Headroom / Look Room
|
||||
- 180-degree Rule
|
||||
|
||||
### 7.4 光線(Lighting & Tone)
|
||||
- Three-Point Lighting(Key / Fill / Back)
|
||||
- High Key
|
||||
- Low Key
|
||||
- Hard Light
|
||||
- Soft Light
|
||||
- Practical Light
|
||||
|
||||
---
|
||||
|
||||
## 8. 頁面功能規格
|
||||
|
||||
### 8.1 Landing Page(/pages/index.vue)
|
||||
- 專案介紹
|
||||
- 功能卡片(靜態)
|
||||
- CTA → Dashboard
|
||||
|
||||
### 8.2 Dashboard(/pages/app/dashboard.vue)
|
||||
- 工具入口卡片
|
||||
- 不顯示 AI 結果
|
||||
|
||||
### 8.3 Settings(/pages/app/settings.vue)
|
||||
- Gemini API Key
|
||||
- Grok API Key(optional)
|
||||
- Provider 切換
|
||||
|
||||
### 8.4 AI 分鏡表
|
||||
- 輸入故事、風格、節奏
|
||||
- Gemini 回傳真實分鏡結果
|
||||
- 以卡片顯示
|
||||
|
||||
### 8.5 AI 攝影機
|
||||
- 上傳圖片
|
||||
- Gemini 回傳鏡位與運鏡建議
|
||||
|
||||
### 8.6 Sora 導演
|
||||
- 一句話 prompt
|
||||
- Gemini 回傳影片規劃文字
|
||||
|
||||
### 8.7 Cut Agent
|
||||
- 素材 + 劇情
|
||||
- Gemini 回傳剪輯建議
|
||||
|
||||
---
|
||||
|
||||
## 9. UI 原則
|
||||
- 卡片導向
|
||||
- 可讀性優先
|
||||
- 不追求動畫
|
||||
- 結構穩定、可擴充
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# Cursor 開發最高準則(Nuxt 4 · Gemini Direct)
|
||||
|
||||
## 1. 前提
|
||||
- 專案已存在
|
||||
- 使用 Nuxt 4
|
||||
- 使用真實 Gemini API
|
||||
- Token 由使用者於設定頁輸入
|
||||
|
||||
---
|
||||
|
||||
## 2. Dependency 規則
|
||||
|
||||
### 規劃階段
|
||||
- 允許討論是否需要新增 dependency
|
||||
- 需說明必要性與替代方案
|
||||
|
||||
### 實作階段(預設)
|
||||
- ❌ 不新增任何 dependency
|
||||
- ❌ 不安裝任何套件
|
||||
- ❌ 不隱性引入 library
|
||||
|
||||
---
|
||||
|
||||
## 3. Component 拆解原則(Token 最小化)
|
||||
|
||||
只允許三層:
|
||||
|
||||
### Base
|
||||
- BaseButton
|
||||
- BaseInput
|
||||
- BaseTextarea
|
||||
- BaseCard
|
||||
|
||||
### Feature
|
||||
- ToolCard
|
||||
- SceneCard
|
||||
- CameraShotCard
|
||||
|
||||
### Page
|
||||
- pages/*.vue
|
||||
- 僅組合元件與呼叫 composable
|
||||
|
||||
---
|
||||
|
||||
## 4. 拆 component 的唯一判斷
|
||||
是否會被兩個以上頁面重用:
|
||||
- 是 → 拆
|
||||
- 否 → 留在頁面
|
||||
|
||||
---
|
||||
|
||||
## 5. AI 呼叫規則
|
||||
|
||||
- UI 不得直接呼叫 Gemini
|
||||
- UI 只呼叫 composable
|
||||
- Prompt 組裝只存在於 utils/ai
|
||||
|
||||
---
|
||||
|
||||
## 6. AI Provider 架構
|
||||
|
||||
### Interface(/types/ai.ts)
|
||||
|
||||
```ts
|
||||
export interface AIProvider {
|
||||
generateStoryboard(input: unknown): Promise<string>
|
||||
analyzeCamera(input: unknown): Promise<string>
|
||||
generateVideoPlan(input: unknown): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini Client
|
||||
- 從 localStorage 讀取 token
|
||||
- 單一責任:呼叫 API
|
||||
|
||||
### Grok Client
|
||||
- 與 Gemini 相同介面
|
||||
- 預設不啟用
|
||||
|
||||
### Provider Selector
|
||||
- 依設定頁選擇 provider
|
||||
- 預設 Gemini
|
||||
|
||||
---
|
||||
|
||||
## 7. 鏡頭語言處理規則
|
||||
|
||||
- 不 hardcode 鏡頭語言
|
||||
- 不寫 enum
|
||||
- 不驗證 AI 選擇是否合理
|
||||
- UI 僅顯示結構化結果
|
||||
|
||||
建議結構:
|
||||
|
||||
```ts
|
||||
{
|
||||
shot: string
|
||||
movement: string
|
||||
composition: string
|
||||
lighting: string
|
||||
description: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Cursor 輸出限制
|
||||
|
||||
- 一次只產生一個檔案
|
||||
- 不跨檔案修改
|
||||
- 不自動優化
|
||||
- 不補未使用功能
|
||||
|
||||
---
|
||||
|
||||
## 9. 開發順序(不可跳)
|
||||
1. Settings Page
|
||||
2. Gemini Client
|
||||
3. Provider Selector
|
||||
4. AI 分鏡表
|
||||
5. 其他工具頁
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxtjs/tailwindcss']
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "nuxt-app",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.2.2",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -0,0 +1,2 @@
|
|||
User-Agent: *
|
||||
Disallow:
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./app/components/**/*.{js,vue,ts}',
|
||||
'./app/layouts/**/*.vue',
|
||||
'./app/pages/**/*.vue',
|
||||
'./app/plugins/**/*.{js,ts}',
|
||||
'./app/app.vue',
|
||||
'./app/error.vue',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue