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