feat: veo3 videos

This commit is contained in:
Nevo David 2025-07-13 20:22:57 +07:00
parent fcb3768493
commit 8eed4a4992
14 changed files with 389 additions and 299 deletions

View File

@ -30,6 +30,23 @@ export class BillingController {
};
}
@Post('/finish-trial')
async finishTrial(@GetOrgFromRequest() org: Organization) {
try {
await this._stripeService.finishTrial(org.paymentId);
} catch (err) {}
return {
finish: true,
};
}
@Get('/is-trial-finished')
async isTrialFinished(@GetOrgFromRequest() org: Organization) {
return {
finished: !org.isTrailing,
};
}
@Post('/subscribe')
subscribe(
@GetOrgFromRequest() org: Organization,

View File

@ -180,6 +180,14 @@ export class MediaController {
return this._mediaService.videoFunction(identifier, functionName, body);
}
@Get('/generate-video/:type/allowed')
generateVideoAllowed(
@GetOrgFromRequest() org: Organization,
@Param('type') type: string
) {
return this._mediaService.generateVideoAllowed(org, type);
}
@Post('/generate-video/:type')
generateVideo(
@GetOrgFromRequest() org: Organization,

View File

@ -0,0 +1,84 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { timer } from '@gitroom/helpers/utils/timer';
import { Button } from '@gitroom/react/form/button';
export const FinishTrial: FC<{ close: () => void }> = (props) => {
const [finished, setFinished] = useState(false);
const fetch = useFetch();
const finishSubscription = useCallback(async () => {
await fetch('/billing/finish-trial', {
method: 'POST',
});
checkFinished();
}, []);
const checkFinished = useCallback(async () => {
const {finished} = await (await fetch('/billing/is-trial-finished')).json();
if (!finished) {
await timer(2000);
return checkFinished();
}
setFinished(true);
}, []);
useEffect(() => {
finishSubscription();
}, []);
return (
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/50">
<div>
<div className="flex gap-[10px] flex-col w-[500px] h-auto bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Finishing Trial'} />
</div>
<button
onClick={props.close}
className="outline-none absolute end-[10px] top-[10px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="relative h-[400px]">
<div className="absolute left-0 top-0 w-full h-full overflow-hidden overflow-y-auto">
<div className="mt-[10px] flex w-full justify-center items-center gap-[10px]">
{!finished && <LoadingComponent height={150} width={150} />}
{finished && (
<div className="flex flex-col">
<div>
You trial has been successfully finished and you have been charged.
</div>
<div className="flex gap-[10px] mt-[20px]">
<Button className="flex-1" onClick={() => window.close()}>Close window</Button>
<Button className="flex-1" onClick={() => props.close()}>Close dialog</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -16,7 +16,7 @@ import { FAQComponent } from '@gitroom/frontend/components/billing/faq.component
import { useSWRConfig } from 'swr';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import interClass from '@gitroom/react/helpers/inter.font';
import { useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useModals } from '@mantine/modals';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
@ -28,6 +28,7 @@ import { useTrack } from '@gitroom/react/helpers/use.track';
import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum';
import { PurchaseCrypto } from '@gitroom/frontend/components/billing/purchase.crypto';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { FinishTrial } from '@gitroom/frontend/components/billing/finish.trial';
export interface Tiers {
month: Array<{
name: 'Pro' | 'Standard';
@ -224,6 +225,8 @@ export const MainBillingComponent: FC<{
const tolt = useTolt();
const track = useTrack();
const t = useT();
const queryParams = useSearchParams();
const [finishTrial, setFinishTrial] = useState(!!queryParams.get('finishTrial'));
const [subscription, setSubscription] = useState<Subscription | undefined>(
sub
@ -399,6 +402,10 @@ export const MainBillingComponent: FC<{
<div>{t('yearly', 'YEARLY')}</div>
</div>
</div>
{finishTrial && (
<FinishTrial close={() => setFinishTrial(false)} />
)}
<div className="flex gap-[16px] [@media(max-width:1024px)]:flex-col [@media(max-width:1024px)]:text-center">
{Object.entries(pricing)
.filter((f) => !isGeneral || f[0] !== 'FREE')

View File

@ -9,6 +9,9 @@ import useSWR from 'swr';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { VideoWrapper } from '@gitroom/frontend/components/videos/video.render.component';
import { FormProvider, useForm } from 'react-hook-form';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { VideoContextWrapper } from '@gitroom/frontend/components/videos/video.context.wrapper';
import { useToaster } from '@gitroom/react/toaster/toaster';
export const Modal: FC<{
close: () => void;
@ -22,6 +25,7 @@ export const Modal: FC<{
const setLocked = useLaunchStore((state) => state.setLocked);
const form = useForm();
const [position, setPosition] = useState('vertical');
const toaster = useToaster();
const loadCredits = useCallback(async () => {
return (
@ -34,12 +38,16 @@ export const Modal: FC<{
const { data, mutate } = useSWR('copilot-credits', loadCredits);
const generate = useCallback(async () => {
await fetch(`/media/generate-video/${type.identifier}/allowed`);
setLoading(true);
close();
setLocked(true);
console.log('lock');
const customParams = form.getValues();
if (!await form.trigger()) {
toaster.show('Please fill all required fields', 'warning');
return ;
}
try {
const image = await fetch(`/media/generate-video/${type.identifier}`, {
method: 'POST',
@ -50,91 +58,90 @@ export const Modal: FC<{
}),
});
console.log(image);
if (image.status == 200 || image.status == 201) {
onChange(await image.json());
}
} catch (e) {}
console.log('remove lock');
setLocked(false);
setLoading(false);
}, [type, value, position]);
return (
<form
onSubmit={form.handleSubmit(generate)}
className="flex flex-col gap-[10px]"
>
<FormProvider {...form}>
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/50">
<div>
<div className="flex gap-[10px] flex-col w-[500px] h-auto bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Video Type'}>
<div className="mr-[25px]">
{data?.credits || 0} credits left
</div>
</TopTitle>
</div>
<button
onClick={props.close}
className="outline-none absolute end-[10px] top-[10px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="relative h-[400px]">
<div className="absolute left-0 top-0 w-full h-full overflow-hidden overflow-y-auto">
<div className="mt-[10px] flex w-full justify-center items-center gap-[10px]">
<div className="flex-1 flex">
<Button
className="!flex-1"
onClick={() => setPosition('vertical')}
secondary={position === 'horizontal'}
>
Vertical (Stories, Reels)
</Button>
</div>
<div className="flex-1 flex mt-[10px]">
<Button
className="!flex-1"
onClick={() => setPosition('horizontal')}
secondary={position === 'vertical'}
>
Horizontal (Normal Post)
</Button>
</div>
<VideoContextWrapper.Provider value={{ value: value }}>
<form
onSubmit={form.handleSubmit(generate)}
className="flex flex-col gap-[10px]"
>
<FormProvider {...form}>
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/50">
<div>
<div className="flex gap-[10px] flex-col w-[500px] h-auto bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Video Type'}>
<div className="mr-[25px]">
{data?.credits || 0} credits left
</div>
</TopTitle>
</div>
<VideoWrapper identifier={type.identifier} />
<button
onClick={props.close}
className="outline-none absolute end-[10px] top-[10px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="relative h-[400px]">
<div className="absolute left-0 top-0 w-full h-full overflow-hidden overflow-y-auto">
<div className="mt-[10px] flex w-full justify-center items-center gap-[10px]">
<div className="flex-1 flex">
<Button
className="!flex-1"
onClick={() => setPosition('vertical')}
secondary={position === 'horizontal'}
>
Vertical (Stories, Reels)
</Button>
</div>
<div className="flex-1 flex mt-[10px]">
<Button
className="!flex-1"
onClick={() => setPosition('horizontal')}
secondary={position === 'vertical'}
>
Horizontal (Normal Post)
</Button>
</div>
</div>
<VideoWrapper identifier={type.identifier} />
</div>
</div>
<div className="flex">
<Button type="submit" className="flex-1">
Generate
</Button>
</div>
</div>
<div className="flex">
<Button type="submit" className="flex-1">
Generate
</Button>
</div>
</div>
</div>
</div>
</FormProvider>
</form>
</FormProvider>
</form>
</VideoContextWrapper.Provider>
);
};
@ -148,6 +155,7 @@ export const AiVideo: FC<{
const [type, setType] = useState<any | null>(null);
const [modal, setModal] = useState(false);
const fetch = useFetch();
const { isTrailing } = useUser();
const loadVideoList = useCallback(async () => {
return (await (await fetch('/media/video-options')).json()).filter(

View File

@ -85,6 +85,21 @@ function LayoutContextInner(params: { children: ReactNode }) {
}
window.location.href = '/';
}
if (response.status === 406) {
if (
await deleteDialog(
'You are currently on trial, in order to use the feature you must finish the trial',
'Finish the trial, charge me now',
'Trial',
)
) {
window.open('/billing?finishTrial=true', '_blank');
return false;
}
return false;
}
if (response.status === 402) {
if (
await deleteDialog(

View File

@ -17,6 +17,7 @@ export const UserContext = createContext<
isLifetime?: boolean;
impersonate: boolean;
allowTrial: boolean;
isTrailing: boolean;
})
>(undefined);
export const ContextWrapper: FC<{

View File

@ -0,0 +1,64 @@
import { videoWrapper } from '@gitroom/frontend/components/videos/video.wrapper';
import { FC, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useVideo } from '@gitroom/frontend/components/videos/video.context.wrapper';
import { Textarea } from '@gitroom/react/form/textarea';
import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component';
export interface Voice {
id: string;
name: string;
preview_url: string;
}
const VEO3Settings: FC = () => {
const { register, watch, setValue, formState } = useFormContext();
const { value } = useVideo();
const [videoValue, setVideoValue] = useState(value);
const media = register('media', {
value: [],
});
const mediaValue = watch('media');
return (
<div>
<Textarea
label="Prompt"
name="prompt"
{...register('prompt', {
required: true,
minLength: 5,
value,
})}
error={formState?.errors?.prompt?.message}
/>
<div className="mb-[6px]">Images (max 3)</div>
<MultiMediaComponent
allData={[]}
dummy={true}
text="Images"
description="Images"
name="images"
label="Media"
value={mediaValue}
onChange={(val) =>
setValue(
'images',
val.target.value
.filter((f) => f.path.indexOf('mp4') === -1)
.slice(0, 3)
)
}
error={formState?.errors?.media?.message}
/>
</div>
);
};
const VeoComponent = () => {
return <VEO3Settings />;
};
videoWrapper('veo3', VeoComponent);

View File

@ -0,0 +1,4 @@
import { createContext, useContext } from 'react';
export const VideoContextWrapper = createContext({value: ''});
export const useVideo = () => useContext(VideoContextWrapper);

View File

@ -1,7 +1,9 @@
import { createContext, FC, useCallback, useContext } from 'react';
import { createContext, FC, useCallback, useContext, useEffect } from 'react';
import './providers/image-text-slides.provider';
import './providers/veo3.provider';
import { videosList } from '@gitroom/frontend/components/videos/video.wrapper';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
const VideoFunctionWrapper = createContext({
identifier: '',
@ -28,6 +30,14 @@ export const useVideoFunction = () => {
};
export const VideoWrapper: FC<{ identifier: string }> = (props) => {
const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton);
useEffect(() => {
setActivateExitButton(false);
return () => {
setActivateExitButton(true);
};
}, []);
const { identifier } = props;
const Component = videosList.find(
(v) => v.identifier === identifier

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media/media.repository';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
@ -7,7 +7,11 @@ import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/sa
import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { AuthorizationActions, Sections, SubscriptionException } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import {
AuthorizationActions,
Sections,
SubscriptionException,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
@Injectable()
export class MediaService {
@ -61,6 +65,19 @@ export class MediaService {
return this._videoManager.getAllVideos();
}
async generateVideoAllowed(org: Organization, type: string) {
const video = this._videoManager.getVideoByName(type);
if (!video) {
throw new Error(`Video type ${type} not found`);
}
if (!video.trial && org.isTrailing) {
throw new HttpException('This video is not available in trial mode', 406);
}
return true;
}
async generateVideo(org: Organization, body: VideoDto, type: string) {
const totalCredits = await this._subscriptionService.checkCredits(
org,
@ -78,6 +95,10 @@ export class MediaService {
throw new Error(`Video type ${type} not found`);
}
if (!video.trial && org.isTrailing) {
throw new HttpException('This video is not available in trial mode', 406);
}
const loadedData = await video.instance.process(
body.prompt,
body.output,

View File

@ -141,7 +141,7 @@ export class StripeService {
}
return this._subscriptionService.createOrUpdateSubscription(
event.data.object.status === 'trialing',
event.data.object.status !== 'active',
uniqueId,
event.data.object.customer as string,
pricing[billing].channel!,
@ -169,13 +169,13 @@ export class StripeService {
}
return this._subscriptionService.createOrUpdateSubscription(
event.data.object.status === 'trialing',
event.data.object.status !== 'active',
uniqueId,
event.data.object.customer as string,
pricing[billing].channel!,
billing,
period,
event.data.object.cancel_at,
event.data.object.cancel_at
);
}
@ -463,6 +463,18 @@ export class StripeService {
return accountLink.url;
}
async finishTrial(paymentId: string) {
const list = (
await stripe.subscriptions.list({
customer: paymentId,
})
).data.filter((f) => f.status === 'trialing');
return stripe.subscriptions.update(list[0].id, {
trial_end: 'now',
});
}
async checkSubscription(organizationId: string, subscriptionId: string) {
const orgValue = await this._subscriptionService.checkSubscription(
organizationId,

View File

@ -1,30 +1,10 @@
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import {
Prompt,
URL,
Video,
VideoAbstract,
} from '@gitroom/nestjs-libraries/videos/video.interface';
import { chunk } from 'lodash';
import Transloadit from 'transloadit';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { Readable } from 'stream';
import { parseBuffer } from 'music-metadata';
import { stringifySync } from 'subtitle';
import pLimit from 'p-limit';
import { FalService } from '@gitroom/nestjs-libraries/openai/fal.service';
const limit = pLimit(2);
const transloadit = new Transloadit({
authKey: process.env.TRANSLOADIT_AUTH || 'just empty text',
authSecret: process.env.TRANSLOADIT_SECRET || 'just empty text',
});
async function getAudioDuration(buffer: Buffer): Promise<number> {
const metadata = await parseBuffer(buffer, 'audio/mpeg');
return metadata.format.duration || 0;
}
import { timer } from '@gitroom/helpers/utils/timer';
@Video({
identifier: 'veo3',
@ -32,213 +12,71 @@ async function getAudioDuration(buffer: Buffer): Promise<number> {
description: 'Generate videos with the most advanced video model.',
placement: 'text-to-image',
trial: false,
available:
!!process.env.TRANSLOADIT_AUTH &&
!!process.env.TRANSLOADIT_SECRET &&
!!process.env.OPENAI_API_KEY &&
!!process.env.KIEAI_API_KEY,
available: !!process.env.KIEAI_API_KEY,
})
export class Veo3 extends VideoAbstract {
private storage = UploadFactory.createStorage();
constructor(
private _openaiService: OpenaiService,
private _falService: FalService
) {
super();
}
async process(
prompt: Prompt[],
output: 'vertical' | 'horizontal',
customParams: { voice: string }
customParams: { prompt: string; images: { id: string; path: string }[] }
): Promise<URL> {
const list = await this._openaiService.generateSlidesFromText(
prompt[0].value
);
const generated = await Promise.all(
list.reduce((all, current) => {
all.push(
new Promise(async (res) => {
res({
len: 0,
url: await this._falService.generateImageFromText(
'ideogram/v2',
current.imagePrompt,
output === 'vertical'
),
});
})
);
all.push(
new Promise(async (res) => {
const buffer = Buffer.from(
await (
await limit(() =>
fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${customParams.voice}?output_format=mp3_44100_128`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'xi-api-key': process.env.ELEVENSLABS_API_KEY || '',
},
body: JSON.stringify({
text: current.voiceText,
model_id: 'eleven_multilingual_v2'
}),
}
)
)
).arrayBuffer()
);
const { path } = await this.storage.uploadFile({
buffer,
mimetype: 'audio/mp3',
size: buffer.length,
path: '',
fieldname: '',
destination: '',
stream: new Readable(),
filename: '',
originalname: '',
encoding: '',
});
res({
len: await getAudioDuration(buffer),
url:
path.indexOf('http') === -1
? process.env.FRONTEND_URL +
'/' +
process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY +
path
: path,
});
})
);
return all;
}, [] as Promise<any>[])
);
const split = chunk(generated, 2);
const srt = stringifySync(
list
.reduce((all, current, index) => {
const start = all.length ? all[all.length - 1].end : 0;
const end = start + split[index][1].len * 1000 + 1000;
all.push({
start: start,
end: end,
text: current.voiceText,
});
return all;
}, [] as { start: number; end: number; text: string }[])
.map((item) => ({
type: 'cue',
data: item,
})),
{ format: 'SRT' }
);
const { results } = await transloadit.createAssembly({
uploads: {
'subtitles.srt': srt,
},
waitForCompletion: true,
params: {
steps: {
...split.reduce((all, current, index) => {
all[`image${index}`] = {
robot: '/http/import',
url: current[0].url,
};
all[`audio${index}`] = {
robot: '/http/import',
url: current[1].url,
};
all[`merge${index}`] = {
use: [
{
name: `image${index}`,
as: 'image',
},
{
name: `audio${index}`,
as: 'audio',
},
],
robot: '/video/merge',
duration: current[1].len + 1,
audio_delay: 0.5,
preset: 'hls-1080p',
resize_strategy: 'min_fit',
loop: true,
};
return all;
}, {} as any),
concatenated: {
robot: '/video/concat',
result: false,
video_fade_seconds: 0.5,
use: split.map((p, index) => ({
name: `merge${index}`,
as: `video_${index + 1}`,
})),
},
subtitled: {
robot: '/video/subtitle',
result: true,
preset: 'hls-1080p',
use: {
bundle_steps: true,
steps: [
{
name: 'concatenated',
as: 'video',
},
{
name: ':original',
as: 'subtitles',
},
],
},
position: 'center',
font_size: 8,
subtitles_type: 'burned',
},
},
console.log({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.KIEAI_API_KEY}`,
},
method: 'POST',
body: JSON.stringify({
prompt: customParams.prompt,
imageUrls: customParams?.images?.map((p) => p.path) || [],
model: 'veo3_fast',
aspectRatio: output === 'horizontal' ? '16:9' : '9:16',
}),
});
return results.subtitled[0].url;
}
async loadVoices(data: any) {
const { voices } = await (
await fetch(
'https://api.elevenlabs.io/v2/voices?page_size=40&category=premade',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'xi-api-key': process.env.ELEVENSLABS_API_KEY || '',
},
}
)
const value = await (
await fetch('https://api.kie.ai/api/v1/veo/generate', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.KIEAI_API_KEY}`,
},
method: 'POST',
body: JSON.stringify({
prompt: customParams.prompt,
imageUrls: customParams?.images?.map((p) => p.path) || [],
model: 'veo3_fast',
aspectRatio: output === 'horizontal' ? '16:9' : '9:16',
}),
})
).json();
return {
voices: voices.map((voice: any) => ({
id: voice.voice_id,
name: voice.name,
preview_url: voice.preview_url,
})),
};
if (value.code !== 200 && value.code !== 201) {
throw new Error(`Failed to generate video`);
}
const taskId = value.data.taskId;
let videoUrl = [];
while (videoUrl.length === 0) {
console.log('waiting for video to be ready');
const data = await (
await fetch(
'https://api.kie.ai/api/v1/veo/record-info?taskId=' + taskId,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.KIEAI_API_KEY}`,
},
}
)
).json();
if (data.code !== 200 && data.code !== 400) {
throw new Error(`Failed to get video info`);
}
videoUrl = data?.data?.response?.resultUrls || [];
await timer(10000);
}
return videoUrl[0];
}
}

View File

@ -4,7 +4,8 @@ import i18next from '@gitroom/react/translation/i18next';
export const deleteDialog = async (
message: string,
confirmButton?: string,
title?: string
title?: string,
cancelButton?: string
) => {
const fire = await Swal.fire({
title: title || i18next.t('are_you_sure', 'Are you sure?'),
@ -13,7 +14,7 @@ export const deleteDialog = async (
showCancelButton: true,
confirmButtonText:
confirmButton || i18next.t('yes_delete_it', 'Yes, delete it!'),
cancelButtonText: i18next.t('no_cancel', 'No, cancel!'),
cancelButtonText: cancelButton || i18next.t('no_cancel', 'No, cancel!'),
});
return fire.isConfirmed;
};