Skip to content

Commit 472f366

Browse files
authored
Merge pull request #55 from WuMingDao/dev
feat: add image-to-video generation
2 parents 4f83c21 + ac2dccb commit 472f366

14 files changed

Lines changed: 719 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ batch generation, and one-click deployment to Cloudflare Pages.
2121
## Features
2222

2323
- **Multiple AI Providers** - Gitee AI, HuggingFace Spaces, ModelScope
24+
- **Image-to-Video** - Generate videos from images (Gitee AI)
2425
- **Dark Mode UI** - Gradio-style with frosted glass effects
2526
- **Flexible Sizing** - Multiple aspect ratios (1:1, 16:9, 9:16, 4:3, etc.)
2627
- **4x Upscaling** - RealESRGAN integration

README.zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
## 功能特性
2222

2323
- **多 AI 提供商** - Gitee AI、HuggingFace Spaces、ModelScope
24+
- **图片转视频** - 从图片生成视频 (Gitee AI)
2425
- **深色模式 UI** - Gradio 风格毛玻璃效果
2526
- **灵活尺寸** - 多种宽高比 (1:1, 16:9, 9:16, 4:3 等)
2627
- **4x 放大** - RealESRGAN 集成

apps/api/src/app.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
TRANSLATION_CONFIG,
2727
type TranslateRequest,
2828
type TranslateResponse,
29+
type VideoGenerateRequest,
2930
validateDimensions,
3031
validatePrompt,
3132
validateScale,
@@ -45,6 +46,7 @@ import {
4546
timeout,
4647
} from './middleware'
4748
import { getProvider, hasProvider } from './providers'
49+
import { createVideoTask, getVideoTaskStatus } from './providers/gitee'
4850
import { callGradioApi, formatDimensions, formatDuration } from './utils'
4951

5052
export interface AppConfig {
@@ -88,13 +90,16 @@ export function createApp(config: AppConfig = {}) {
8890
app.use('/upscale', timeout(120000))
8991
app.use('/optimize', timeout(60000)) // 60 seconds for LLM
9092
app.use('/translate', timeout(30000)) // 30 seconds for translation
93+
app.use('/video/generate', timeout(30000)) // 30 seconds for video task creation
94+
app.use('/video/status/*', timeout(30000)) // 30 seconds for status check
9195

9296
// Apply body limit (50KB for most endpoints)
9397
app.use('/generate', bodyLimit(50 * 1024))
9498
app.use('/generate-hf', bodyLimit(50 * 1024))
9599
app.use('/upscale', bodyLimit(50 * 1024))
96100
app.use('/optimize', bodyLimit(50 * 1024))
97101
app.use('/translate', bodyLimit(20 * 1024)) // 20KB for translation
102+
app.use('/video/generate', bodyLimit(50 * 1024))
98103

99104
// Health check
100105
app.get('/', (c) => {
@@ -522,6 +527,69 @@ export function createApp(config: AppConfig = {}) {
522527
}
523528
})
524529

530+
// Video generation - Create task
531+
app.post('/video/generate', async (c) => {
532+
let body: VideoGenerateRequest
533+
try {
534+
body = await c.req.json()
535+
} catch {
536+
return sendError(c, Errors.invalidParams('body', 'Invalid JSON body'))
537+
}
538+
539+
const authToken = c.req.header('X-API-Key')
540+
541+
if (!authToken) {
542+
return sendError(c, Errors.authRequired('Gitee AI'))
543+
}
544+
545+
if (body.provider !== 'gitee') {
546+
return c.json({ error: 'Use frontend direct call for HuggingFace' }, 400)
547+
}
548+
549+
if (!body.imageUrl || !body.prompt) {
550+
return sendError(c, Errors.invalidParams('body', 'imageUrl and prompt are required'))
551+
}
552+
553+
const dimensionsValidation = validateDimensions(body.width, body.height)
554+
if (!dimensionsValidation.valid) {
555+
return sendError(
556+
c,
557+
Errors.invalidDimensions(dimensionsValidation.error || 'Invalid dimensions')
558+
)
559+
}
560+
561+
try {
562+
const taskId = await createVideoTask(
563+
body.imageUrl,
564+
body.prompt,
565+
body.width,
566+
body.height,
567+
authToken
568+
)
569+
570+
return c.json({ taskId, status: 'pending' })
571+
} catch (err) {
572+
return sendError(c, err)
573+
}
574+
})
575+
576+
// Video generation - Query status
577+
app.get('/video/status/:taskId', async (c) => {
578+
const taskId = c.req.param('taskId')
579+
const authToken = c.req.header('X-API-Key')
580+
581+
if (!authToken) {
582+
return sendError(c, Errors.authRequired('Gitee AI'))
583+
}
584+
585+
try {
586+
const result = await getVideoTaskStatus(taskId, authToken)
587+
return c.json(result)
588+
} catch (err) {
589+
return sendError(c, err)
590+
}
591+
})
592+
525593
return app
526594
}
527595

apps/api/src/providers/gitee.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
* Gitee AI Provider Implementation
33
*/
44

5-
import { Errors } from '@z-image/shared'
5+
import type { VideoTaskResponse } from '@z-image/shared'
6+
import { Errors, VIDEO_NEGATIVE_PROMPT } from '@z-image/shared'
67
import type { ImageProvider, ProviderGenerateRequest, ProviderGenerateResult } from './types'
78

89
const GITEE_API_URL = 'https://ai.gitee.com/v1/images/generations'
10+
const GITEE_VIDEO_API = 'https://ai.gitee.com/v1/async/videos/image-to-video'
11+
const GITEE_TASK_API = 'https://ai.gitee.com/api/v1/task'
912

1013
interface GiteeImageResponse {
1114
data: Array<{
@@ -115,3 +118,76 @@ export class GiteeProvider implements ImageProvider {
115118
}
116119

117120
export const giteeProvider = new GiteeProvider()
121+
122+
interface GiteeVideoTaskResponse {
123+
task_id: string
124+
}
125+
126+
interface GiteeTaskStatusResponse {
127+
status: 'pending' | 'is_process' | 'success' | 'failure'
128+
output?: {
129+
file_url?: string
130+
error?: string
131+
}
132+
}
133+
134+
export async function createVideoTask(
135+
imageUrl: string,
136+
prompt: string,
137+
width: number,
138+
height: number,
139+
authToken: string
140+
): Promise<string> {
141+
const formData = new FormData()
142+
formData.append('image', imageUrl)
143+
formData.append('prompt', prompt)
144+
formData.append('negative_prompt', VIDEO_NEGATIVE_PROMPT)
145+
formData.append('model', 'Wan2_2-I2V-A14B')
146+
formData.append('num_inference_steps', '6')
147+
formData.append('num_frames', '48')
148+
formData.append('guidance_scale', '1')
149+
formData.append('width', width.toString())
150+
formData.append('height', height.toString())
151+
152+
const response = await fetch(GITEE_VIDEO_API, {
153+
method: 'POST',
154+
headers: {
155+
Authorization: `Bearer ${authToken.trim()}`,
156+
},
157+
body: formData,
158+
})
159+
160+
if (!response.ok) {
161+
const errData = (await response.json().catch(() => ({}))) as GiteeErrorResponse
162+
throw parseGiteeError(response.status, errData)
163+
}
164+
165+
const data = (await response.json()) as GiteeVideoTaskResponse
166+
return data.task_id
167+
}
168+
169+
export async function getVideoTaskStatus(
170+
taskId: string,
171+
authToken: string
172+
): Promise<VideoTaskResponse> {
173+
const response = await fetch(`${GITEE_TASK_API}/${taskId}`, {
174+
headers: {
175+
Authorization: `Bearer ${authToken.trim()}`,
176+
},
177+
})
178+
179+
if (!response.ok) {
180+
const errData = (await response.json().catch(() => ({}))) as GiteeErrorResponse
181+
throw parseGiteeError(response.status, errData)
182+
}
183+
184+
const data = (await response.json()) as GiteeTaskStatusResponse
185+
186+
if (data.status === 'success') {
187+
return { status: 'success', videoUrl: data.output?.file_url }
188+
}
189+
if (data.status === 'failure') {
190+
return { status: 'failed', error: data.output?.error }
191+
}
192+
return { status: data.status === 'is_process' ? 'processing' : 'pending' }
193+
}

apps/web/src/components/feature/ImageResultCard.tsx

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Info,
1010
Loader2,
1111
Trash2,
12+
Video,
1213
X,
1314
ZoomIn,
1415
ZoomOut,
@@ -17,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
1718
import { toast } from 'sonner'
1819
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
1920
import { ImageComparison } from '@/components/ui/ImageComparison'
21+
import { useVideoGenerator } from '@/hooks/useVideoGenerator'
2022
import { upscaleImage } from '@/lib/api'
2123

2224
interface ImageResultCardProps {
@@ -27,6 +29,7 @@ interface ImageResultCardProps {
2729
isBlurred: boolean
2830
isUpscaled: boolean
2931
isUpscaling: boolean
32+
giteeToken?: string
3033
setShowInfo: (v: boolean) => void
3134
setIsBlurred: (v: boolean) => void
3235
handleUpscale: () => void
@@ -42,6 +45,7 @@ export function ImageResultCard({
4245
isBlurred,
4346
isUpscaled,
4447
isUpscaling: externalIsUpscaling,
48+
giteeToken,
4549
setShowInfo,
4650
setIsBlurred,
4751
handleUpscale: _externalHandleUpscale,
@@ -63,6 +67,10 @@ export function ImageResultCard({
6367
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 })
6468
const imageContainerRef = useRef<HTMLDivElement>(null)
6569

70+
// Video generation state
71+
const { videoState, generateVideo } = useVideoGenerator()
72+
const [showVideo, setShowVideo] = useState(false)
73+
6674
// Use display URL if set (after applying upscale), otherwise original
6775
const currentImageUrl = displayUrl || imageDetails?.url
6876

@@ -119,6 +127,32 @@ export function ImageResultCard({
119127
setIsComparing(false)
120128
}, [])
121129

130+
// Handle video generation
131+
const handleGenerateVideo = useCallback(async () => {
132+
if (!currentImageUrl || !imageDetails) return
133+
134+
if (!giteeToken) {
135+
toast.error('Please configure Gitee AI token first')
136+
return
137+
}
138+
139+
const width = Number.parseInt(imageDetails.dimensions.split('x')[0], 10) || 1024
140+
const height = Number.parseInt(imageDetails.dimensions.split('x')[1], 10) || 1024
141+
142+
toast.info('Starting video generation...')
143+
await generateVideo(currentImageUrl, imageDetails.prompt, width, height, 'gitee', giteeToken)
144+
}, [currentImageUrl, imageDetails, generateVideo, giteeToken])
145+
146+
// Show video when generation succeeds
147+
useEffect(() => {
148+
if (videoState.status === 'success') {
149+
setShowVideo(true)
150+
toast.success('Video generated successfully!')
151+
} else if (videoState.status === 'failed') {
152+
toast.error(videoState.error || 'Video generation failed')
153+
}
154+
}, [videoState.status, videoState.error])
155+
122156
// Zoom controls
123157
const handleZoomIn = useCallback(() => {
124158
setScale((s) => Math.min(s * 1.5, 8))
@@ -231,14 +265,25 @@ export function ImageResultCard({
231265
<div className="relative rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 group">
232266
{imageDetails ? (
233267
<>
234-
{/* Normal image display with double-click to fullscreen */}
235-
<img
236-
src={currentImageUrl || ''}
237-
alt="Generated"
238-
className={`w-full transition-all duration-300 cursor-pointer ${isBlurred ? 'blur-xl' : ''}`}
239-
onDoubleClick={handleDoubleClick}
240-
title="Double-click to view fullscreen"
241-
/>
268+
{/* Video or Image display */}
269+
{showVideo && videoState.videoUrl ? (
270+
<video
271+
src={videoState.videoUrl}
272+
controls
273+
autoPlay
274+
loop
275+
muted
276+
className="w-full"
277+
/>
278+
) : (
279+
<img
280+
src={currentImageUrl || ''}
281+
alt="Generated"
282+
className={`w-full transition-all duration-300 cursor-pointer ${isBlurred ? 'blur-xl' : ''}`}
283+
onDoubleClick={handleDoubleClick}
284+
title="Double-click to view fullscreen"
285+
/>
286+
)}
242287

243288
{/* Floating Toolbar */}
244289
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 pointer-events-none">
@@ -271,6 +316,39 @@ export function ImageResultCard({
271316
{isBlurred ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
272317
</button>
273318
<div className="w-px h-5 bg-white/10" />
319+
{/* Generate Video */}
320+
<button
321+
type="button"
322+
onClick={
323+
videoState.status === 'success'
324+
? () => setShowVideo(!showVideo)
325+
: handleGenerateVideo
326+
}
327+
disabled={
328+
videoState.status === 'generating' || videoState.status === 'polling'
329+
}
330+
title={
331+
videoState.status === 'generating' || videoState.status === 'polling'
332+
? 'Generating video...'
333+
: videoState.status === 'success'
334+
? showVideo
335+
? 'Show image'
336+
: 'Show video'
337+
: 'Generate video'
338+
}
339+
className={`flex items-center justify-center w-10 h-10 rounded-xl transition-all ${
340+
videoState.status === 'success'
341+
? 'text-green-400 bg-green-500/10'
342+
: 'text-white/70 hover:text-white hover:bg-white/10'
343+
} disabled:cursor-not-allowed disabled:opacity-50`}
344+
>
345+
{videoState.status === 'generating' || videoState.status === 'polling' ? (
346+
<Loader2 className="w-5 h-5 animate-spin text-orange-400" />
347+
) : (
348+
<Video className="w-5 h-5" />
349+
)}
350+
</button>
351+
<div className="w-px h-5 bg-white/10" />
274352
{/* Download */}
275353
<button
276354
type="button"

0 commit comments

Comments
 (0)