Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ jobs:
echo "📍 Submodule commit: $CURRENT_COMMIT"

# Check if this version exists on npm
if npm view @answerai/aai-embed@$EMBED_VERSION version 2>/dev/null; then
echo "✅ @answerai/aai-embed@$EMBED_VERSION is published on npm"
if npm view aai-embed@$EMBED_VERSION version 2>/dev/null; then
echo "✅ aai-embed@$EMBED_VERSION is published on npm"

# Optional: Verify git tag matches commit for extra safety
EXPECTED_TAG="v${EMBED_VERSION}"
Expand All @@ -132,13 +132,13 @@ jobs:
echo "is_published=true" >> $GITHUB_OUTPUT
echo "embed_version=$EMBED_VERSION" >> $GITHUB_OUTPUT
else
echo "❌ @answerai/aai-embed@$EMBED_VERSION NOT found on npm"
echo "❌ aai-embed@$EMBED_VERSION NOT found on npm"
echo ""
echo "🛑 Cannot publish aai-embed-react because aai-embed@$EMBED_VERSION hasn't been published yet."
echo ""
echo "Next steps:"
echo "1. Ensure aai-embed workflow ran successfully in the chat-embed repository"
echo "2. Verify the package was published: npm view @answerai/aai-embed@$EMBED_VERSION"
echo "2. Verify the package was published: npm view aai-embed@$EMBED_VERSION"
echo "3. If not published, trigger the workflow manually in the chat-embed repo"

echo "is_published=false" >> $GITHUB_OUTPUT
Expand All @@ -162,12 +162,19 @@ jobs:
packages: write
id-token: write
steps:
- name: Generate GitHub App Token
id: generate-token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.STAGING_RESET_APP_ID }}
private_key: ${{ secrets.STAGING_RESET_APP_PRIVATE_KEY }}

- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}

- name: Setup Node.js
uses: actions/setup-node@v4
Expand Down Expand Up @@ -253,8 +260,7 @@ jobs:
- name: Update package metadata
working-directory: ./packages/embed-react
run: |
# Update package name to scoped name
node -e "const pkg = require('./package.json'); pkg.name = '@answerai/aai-embed-react'; require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
# Keep package name as-is (aai-embed-react)

# Add publishConfig
node -e "const pkg = require('./package.json'); pkg.publishConfig = { access: 'public' }; require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
Expand Down Expand Up @@ -309,6 +315,6 @@ jobs:

- name: Summary
run: |
echo "✅ Published @answerai/aai-embed-react@${{ steps.version.outputs.new_version }} to npm"
echo "✅ Published aai-embed-react@${{ steps.version.outputs.new_version }} to npm"
echo "✅ Peer dependency: aai-embed@^${{ steps.embed_version.outputs.embed_version }}"
echo "✅ Created tag aai-embed-react-v${{ steps.version.outputs.new_version }}"
35 changes: 29 additions & 6 deletions packages-answers/ui/src/VideoCreator/VideoCreator.Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,18 @@ const getProviderForModel = (model: string): Provider => {
return 'openai'
}

const durationOptions = [4, 6, 8, 10, 12, 15]
/**
* Provider-specific duration constraints based on provider API documentation.
* Frontend uses these discrete sets so users cannot pick unsupported durations.
*/
const getProviderDurationOptions = (provider: Provider): number[] => {
if (provider === 'openai') {
// OpenAI Sora models support only 4, 8, and 12 second videos.
return [4, 8, 12]
}
// Google Veo models support discrete duration values.
return [4, 6, 8]
}

const formatDateTime = (value?: string) => {
if (!value) return 'Unknown date'
Expand Down Expand Up @@ -236,6 +247,7 @@ const VideoCreator = () => {
const [dialogEmotion, setDialogEmotion] = useState('calm')

const provider = useMemo(() => getProviderForModel(model), [model])
const durationOptions = useMemo(() => getProviderDurationOptions(provider), [provider])
const jobPollers = useRef<Map<string, number>>(new Map())

useEffect(() => {
Expand Down Expand Up @@ -333,6 +345,8 @@ const VideoCreator = () => {
}

useEffect(() => {
const validDurations = getProviderDurationOptions(provider)

if (provider === 'google') {
// Veo: 1080p only supports 16:9, 720p supports both 16:9 and 9:16
setAspectRatio((current) => {
Expand All @@ -344,10 +358,16 @@ const VideoCreator = () => {
return current
})
setSize((current) => (current === '1280x720' || current === '1920x1080' ? current : '1280x720'))
setSeconds((current) => (durationOptions.includes(current) ? current : 8))
setSeconds((current) => (validDurations.includes(current) ? current : 8))
} else {
// OpenAI: validate against strict constraints [4, 8, 12]
setSize((current) => (current === '1280x720' || current === '1920x1080' ? current : '1280x720'))
setSeconds((current) => (durationOptions.includes(current) ? current : 8))
setSeconds((current) => {
if (validDurations.includes(current)) return current
// Find closest valid duration for OpenAI (4, 8, or 12)
const closest = validDurations.reduce((prev, curr) => (Math.abs(curr - current) < Math.abs(prev - current) ? curr : prev))
return closest
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
Expand Down Expand Up @@ -1087,9 +1107,12 @@ const VideoCreator = () => {
<Slider
value={seconds}
onChange={(_, newValue) => setSeconds(newValue as number)}
min={4}
max={15}
step={1}
min={Math.min(...durationOptions)}
max={Math.max(...durationOptions)}
step={null}
marks={durationOptions.map((duration) => ({
value: duration
}))}
disabled={loading}
size='small'
sx={{ flexGrow: 1 }}
Expand Down
2 changes: 1 addition & 1 deletion packages/embed
Submodule embed updated 1 files
+2 −2 package.json
10 changes: 7 additions & 3 deletions packages/server/src/aai-utils/billing/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ export const BILLING_CONFIG = {

// Sync configuration
SYNC: {
LOOKBACK_DAYS: parseInt(process.env.BILLING_SYNC_LOOKBACK_DAYS || '90'),
PAGE_BATCH_SIZE: 15, // Number of pages to fetch in parallel
RATE_LIMIT_DELAY_MS: 1000 // Delay between page batches
LOOKBACK_DAYS: parseInt(process.env.BILLING_SYNC_LOOKBACK_DAYS || '7'), // Reduced from 90 to 7 days
PAGE_BATCH_SIZE: parseInt(process.env.BILLING_SYNC_PAGE_BATCH_SIZE || '3'), // Reduced from 15 to 3 pages
RATE_LIMIT_DELAY_MS: parseInt(process.env.BILLING_SYNC_RATE_LIMIT_MS || '2000'), // Increased from 1000 to 2000ms
TRACE_BATCH_SIZE: parseInt(process.env.BILLING_SYNC_TRACE_BATCH_SIZE || '5'), // Reduced from 15 to 5 traces
MAX_RETRIES: 3,
RETRY_DELAY_MS: 1000,
EXPONENTIAL_BACKOFF: true
},

// Resource configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class LangfuseProvider {
page?: number
userId?: string
filter?: any[]
fields?: string
}): Promise<any> {
const queryParams: any = { ...params }

Expand Down Expand Up @@ -218,12 +219,14 @@ export class LangfuseProvider {
})

// Fetch and process first page
// Use minimal fields for initial filtering - exclude observations & scores for performance
const initialResponse = await this.fetchTraces({
fromTimestamp: fromTimestamp.toISOString(),
toTimestamp: toTimestamp.toISOString(),
limit: 100,
page: 1,
filter: LangfuseProvider.UNPROCESSED_FILTER
filter: LangfuseProvider.UNPROCESSED_FILTER,
fields: 'core,metrics,io' // Exclude observations & scores - reduces payload by 80-90%
})
// console.log('initialResponse', initialResponse)
const totalPages = initialResponse.meta.totalPages
Expand Down Expand Up @@ -312,7 +315,8 @@ export class LangfuseProvider {
toTimestamp: toTimestamp.toISOString(),
limit: 100,
page,
filter: LangfuseProvider.UNPROCESSED_FILTER
filter: LangfuseProvider.UNPROCESSED_FILTER,
fields: 'core,metrics,io' // Exclude observations & scores - reduces payload by 80-90%
})
)
}
Expand Down Expand Up @@ -547,13 +551,15 @@ export class LangfuseProvider {
startDate.setDate(startDate.getDate() - 30)

// Fetch traces from Langfuse API with pagination
// Use minimal fields for usage events display - exclude observations to reduce load
const langfuseResponse = await this.fetchTraces({
fromTimestamp: startDate.toISOString(),
toTimestamp: endDate.toISOString(),
limit,
page,
userId,
filter: LangfuseProvider.UNPROCESSED_FILTER
filter: LangfuseProvider.UNPROCESSED_FILTER,
fields: 'core,metrics,io' // Exclude observations & scores - faster response
// Note: We can't directly filter by customerId in the API call
// We'll filter the results after fetching
})
Expand Down
48 changes: 42 additions & 6 deletions packages/server/src/services/video-generator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { Blob } from 'node:buffer'
const STORAGE_FOLDER = 'generated-videos'
const OPENAI_MODELS = new Set(['sora-2', 'sora-2-pro'])
const GOOGLE_MODELS = new Set(['veo-3.0-generate-001', 'veo-3.0-fast-generate-001'])
// Duration constraints based on provider API specifications
const OPENAI_VALID_DURATIONS = [4, 8, 12] // OpenAI Sora: discrete values only
const GOOGLE_VALID_DURATIONS = [4, 6, 8] // Google Veo: supported discrete values
const POLL_INTERVAL_MS = 5000
const MAX_POLL_ATTEMPTS = 120 // ~10 minutes
const JOB_RETENTION_MS = 24 * 60 * 60 * 1000 // keep completed jobs for 24 hours
Expand Down Expand Up @@ -148,6 +151,33 @@ const mapOpenAIStatus = (status?: string): JobStatus => {
}
}

/**
* Validates and normalizes video duration based on provider constraints.
* Auto-corrects invalid values to the nearest supported duration instead of failing.
*
* @param seconds - Requested duration in seconds
* @param model - Video generation model name
* @returns Duration guaranteed to be accepted by the downstream provider
*/
const validateAndNormalizeDuration = (seconds: number | undefined, model: string): number => {
if (!seconds) return 8 // Works for both providers

const isOpenAI = OPENAI_MODELS.has(model)
const validDurations = isOpenAI ? OPENAI_VALID_DURATIONS : GOOGLE_VALID_DURATIONS

if (validDurations.includes(seconds)) {
return seconds
}

const normalized = validDurations.reduce((previous, current) =>
Math.abs(current - seconds) < Math.abs(previous - seconds) ? current : previous
)

console.warn(`[Video Generator] Adjusted duration from ${seconds}s to ${normalized}s for model ${model} (provider constraint)`)

return normalized
}

const getStorageType = (): string => {
return process.env.STORAGE_TYPE ? process.env.STORAGE_TYPE : 'local'
}
Expand Down Expand Up @@ -677,6 +707,9 @@ const startOpenAIJob = async (request: VideoGenerationRequest): Promise<VideoJob
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Prompt is required for OpenAI video generation')
}

// Validate and normalize duration once for the entire request lifecycle
const validatedSeconds = validateAndNormalizeDuration(request.seconds, request.model)

let creationResponse: OpenAIVideoStatus

if (request.remixVideoProviderId) {
Expand All @@ -685,7 +718,7 @@ const startOpenAIJob = async (request: VideoGenerationRequest): Promise<VideoJob
}

if (request.size) body.size = request.size
if (request.seconds) body.seconds = request.seconds
if (validatedSeconds) body.seconds = validatedSeconds

const remixResponse = await fetch(`https://api.openai.com/v1/videos/${request.remixVideoProviderId}/remix`, {
method: 'POST',
Expand All @@ -711,8 +744,8 @@ const startOpenAIJob = async (request: VideoGenerationRequest): Promise<VideoJob
formData.append('size', request.size)
}

if (request.seconds) {
formData.append('seconds', request.seconds.toString())
if (validatedSeconds) {
formData.append('seconds', validatedSeconds.toString())
}

if (request.referenceImage?.base64) {
Expand Down Expand Up @@ -762,7 +795,8 @@ const startOpenAIJob = async (request: VideoGenerationRequest): Promise<VideoJob
}

registerJob(job)
setImmediate(() => processOpenAIJob(jobId, { ...request, prompt }, creationResponse))
// Pass normalized seconds for consistent telemetry/logging downstream
setImmediate(() => processOpenAIJob(jobId, { ...request, prompt, seconds: validatedSeconds }, creationResponse))

return job
}
Expand Down Expand Up @@ -1102,14 +1136,15 @@ const startGoogleJob = async (request: VideoGenerationRequest): Promise<VideoJob
}

const client = new GoogleGenAI({ apiKey })
const validatedSeconds = validateAndNormalizeDuration(request.seconds, request.model)

const aspectRatio = request.aspectRatio || deriveAspectRatio(request.size)
const params: GenerateVideosParameters = {
model: request.model,
prompt: request.prompt,
config: {
aspectRatio,
durationSeconds: request.seconds || 8,
durationSeconds: validatedSeconds,
negativePrompt: request.negativePrompt
}
}
Expand Down Expand Up @@ -1145,7 +1180,8 @@ const startGoogleJob = async (request: VideoGenerationRequest): Promise<VideoJob
}

registerJob(job)
setImmediate(() => processGoogleJob(jobId, request, operation))
// Pass normalized seconds for consistent telemetry/logging downstream
setImmediate(() => processGoogleJob(jobId, { ...request, seconds: validatedSeconds }, operation))

return job
}
Expand Down
Loading