Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
commit
1333b0c36b
202 files changed
+63054
No files matched your search
@@ -0,0 +1,446 @@
|
||||
import { normalizeResponseError } from './response-error'
|
||||
import { backendFetch } from './backend'
|
||||
import type { components, paths } from '../generated/backend-openapi'
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
|
||||
|
||||
type OperationFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = NonNullable<paths[TPath][TMethod]>
|
||||
|
||||
type ResponsesFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod>['responses']
|
||||
|
||||
type JsonBodyOf<TResponse> = TResponse extends {
|
||||
content: infer TContent
|
||||
}
|
||||
? TContent extends { 'application/json': infer TJson }
|
||||
? TJson
|
||||
: never
|
||||
: never
|
||||
|
||||
type JsonResponseFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
responses: { 200: infer TResponse }
|
||||
}
|
||||
? JsonBodyOf<TResponse>
|
||||
: never
|
||||
|
||||
type JsonBodyFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
requestBody?: { content: { 'application/json': infer TBody } }
|
||||
}
|
||||
? TBody
|
||||
: never
|
||||
|
||||
type QueryFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
parameters: { query?: infer TQuery }
|
||||
}
|
||||
? TQuery
|
||||
: never
|
||||
|
||||
type HTTPErrorResponse = components["schemas"]["HTTPErrorResponse"]
|
||||
|
||||
type ExactErrorResponseFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TStatus extends number,
|
||||
> = TStatus extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>[TStatus]>
|
||||
: never
|
||||
|
||||
type Fallback4xxErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = '4XX' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['4XX']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type Fallback5xxErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = '5XX' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['5XX']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type DefaultErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = 'default' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['default']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type ExactErrorMembers<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
> = {
|
||||
[TStatus in TExactStatuses[number]]: {
|
||||
ok: false
|
||||
status: TStatus
|
||||
error: ExactErrorResponseFor<TPath, TMethod, TStatus>
|
||||
}
|
||||
}[TExactStatuses[number]]
|
||||
|
||||
type FallbackErrorMembers<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> =
|
||||
| {
|
||||
ok: false
|
||||
status: '4XX'
|
||||
error: Fallback4xxErrorFor<TPath, TMethod>
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
status: '5XX'
|
||||
error: Fallback5xxErrorFor<TPath, TMethod>
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
status: 'default'
|
||||
error: DefaultErrorFor<TPath, TMethod>
|
||||
}
|
||||
|
||||
export type EndpointResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[] = [],
|
||||
> =
|
||||
| {
|
||||
ok: true
|
||||
data: JsonResponseFor<TPath, TMethod>
|
||||
}
|
||||
| ExactErrorMembers<TPath, TMethod, TExactStatuses>
|
||||
| FallbackErrorMembers<TPath, TMethod>
|
||||
|
||||
type SyntheticErrorStatus = '4XX' | '5XX' | 'default'
|
||||
|
||||
export type ApiSuccess<TValue> = TValue extends { ok: true; data: infer TData }
|
||||
? TData
|
||||
: never
|
||||
|
||||
export type ApiErrors<TValue> = TValue extends { ok: false; status: infer TStatus; error: infer TError }
|
||||
? { status: TStatus; error: TError }
|
||||
: never
|
||||
|
||||
function buildQueryString(query: Record<string, unknown> | undefined): string {
|
||||
if (!query) return ''
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined) continue
|
||||
params.set(key, String(value))
|
||||
}
|
||||
const serialized = params.toString()
|
||||
return serialized ? `?${serialized}` : ''
|
||||
}
|
||||
|
||||
function buildJsonRequestInit(body: unknown, init?: RequestInit): RequestInit {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyntheticError(code: string, message: string): HTTPErrorResponse {
|
||||
return { code, message }
|
||||
}
|
||||
|
||||
function resolveFallbackStatus(httpStatus: number): SyntheticErrorStatus {
|
||||
if (httpStatus >= 400 && httpStatus < 500) return '4XX'
|
||||
if (httpStatus >= 500 && httpStatus < 600) return '5XX'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function resolveErrorStatus<TExactStatuses extends readonly number[]>(
|
||||
httpStatus: number,
|
||||
exactErrorStatuses: TExactStatuses,
|
||||
): TExactStatuses[number] | SyntheticErrorStatus {
|
||||
if ((exactErrorStatuses as readonly number[]).includes(httpStatus)) {
|
||||
return httpStatus as TExactStatuses[number]
|
||||
}
|
||||
return resolveFallbackStatus(httpStatus)
|
||||
}
|
||||
|
||||
function buildParsedErrorResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
status: TExactStatuses[number] | SyntheticErrorStatus,
|
||||
payload: unknown,
|
||||
): EndpointResult<TPath, TMethod, TExactStatuses> {
|
||||
const normalized = normalizeResponseError(payload)
|
||||
return {
|
||||
ok: false,
|
||||
status: status === 402 && normalized.code !== 'LTX_INSUFFICIENT_FUNDS' ? '4XX' : status,
|
||||
error: normalized as ExactErrorResponseFor<TPath, TMethod, TExactStatuses[number]>
|
||||
| Fallback4xxErrorFor<TPath, TMethod>
|
||||
| Fallback5xxErrorFor<TPath, TMethod>
|
||||
| DefaultErrorFor<TPath, TMethod>,
|
||||
} as EndpointResult<TPath, TMethod, TExactStatuses>
|
||||
}
|
||||
|
||||
function buildSyntheticErrorResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
status: SyntheticErrorStatus,
|
||||
code: string,
|
||||
message: string,
|
||||
): EndpointResult<TPath, TMethod, TExactStatuses> {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
error: buildSyntheticError(code, message) as Fallback4xxErrorFor<TPath, TMethod>
|
||||
| Fallback5xxErrorFor<TPath, TMethod>
|
||||
| DefaultErrorFor<TPath, TMethod>,
|
||||
} as EndpointResult<TPath, TMethod, TExactStatuses>
|
||||
}
|
||||
|
||||
async function requestEndpointResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
endpoint: TPath,
|
||||
method: TMethod,
|
||||
exactErrorStatuses: TExactStatuses,
|
||||
init?: RequestInit,
|
||||
requestPath?: string,
|
||||
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> {
|
||||
const path = requestPath ?? String(endpoint)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await backendFetch(path, {
|
||||
method: method.toUpperCase(),
|
||||
...init,
|
||||
})
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'NETWORK_ERROR',
|
||||
error instanceof Error ? error.message : 'Request failed before the server responded.',
|
||||
)
|
||||
}
|
||||
|
||||
let text = ''
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
'RESPONSE_READ_FAILED',
|
||||
error instanceof Error ? error.message : 'Failed to read response body.',
|
||||
)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (!text) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'EMPTY_SUCCESS_RESPONSE',
|
||||
`${path} returned an empty response body.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
data: JSON.parse(text) as JsonResponseFor<TPath, TMethod>,
|
||||
}
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'INVALID_SUCCESS_RESPONSE',
|
||||
error instanceof Error ? error.message : 'Server returned invalid JSON.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
`HTTP_${response.status}`,
|
||||
`${response.status} ${response.statusText || 'Request failed'}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as unknown
|
||||
return buildParsedErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveErrorStatus(response.status, exactErrorStatuses),
|
||||
payload,
|
||||
)
|
||||
} catch {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
`HTTP_${response.status}`,
|
||||
text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeEndpointClient<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[] = [],
|
||||
>(
|
||||
endpoint: TPath,
|
||||
method: TMethod,
|
||||
config?: {
|
||||
exactErrorStatuses?: TExactStatuses
|
||||
},
|
||||
) {
|
||||
const exactErrorStatuses = (config?.exactErrorStatuses ?? []) as TExactStatuses
|
||||
|
||||
return (
|
||||
body?: JsonBodyFor<TPath, TMethod>,
|
||||
init?: RequestInit,
|
||||
requestPath?: string,
|
||||
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> => {
|
||||
const requestInit = body === undefined
|
||||
? init
|
||||
: buildJsonRequestInit(body, init)
|
||||
return requestEndpointResult(endpoint, method, exactErrorStatuses, requestInit, requestPath)
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
static getHealth = makeEndpointClient('/health', 'get')
|
||||
|
||||
static getModelDownloadProgress(
|
||||
query: QueryFor<'/api/models/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/models/download/progress', 'get'>> {
|
||||
const path = `/api/models/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/models/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
static listModels(
|
||||
query?: QueryFor<'/api/models', 'get'>,
|
||||
): Promise<EndpointResult<'/api/models', 'get'>> {
|
||||
const path = `/api/models${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/models', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
|
||||
static getLtxRecommendation = makeEndpointClient('/api/models/ltx-recommendation', 'get')
|
||||
|
||||
static getImgGenRecommendation = makeEndpointClient('/api/models/img-gen-recommendation', 'get')
|
||||
|
||||
static getLtxIcLoraRecommendation = makeEndpointClient('/api/models/ltx-ic-lora-recommendation', 'get')
|
||||
|
||||
static getTextEncoderRecommendation = makeEndpointClient('/api/models/text-encoder-recommendation', 'get')
|
||||
|
||||
static describeCheckpoints = makeEndpointClient('/api/models/describe', 'post')
|
||||
|
||||
static getActiveDownload = makeEndpointClient('/api/models/download/active', 'get')
|
||||
|
||||
static getLtxVersions = makeEndpointClient('/api/models/ltx-versions', 'get')
|
||||
|
||||
static setActiveLtxModel = makeEndpointClient('/api/models/active-ltx-model', 'post')
|
||||
|
||||
static startModelDownload = makeEndpointClient('/api/models/download', 'post')
|
||||
|
||||
static deleteModels = makeEndpointClient('/api/models/delete', 'delete')
|
||||
|
||||
static getRuntimePolicy = makeEndpointClient('/api/runtime-policy', 'get')
|
||||
|
||||
static getGpuInfo = makeEndpointClient('/api/gpu-info', 'get')
|
||||
|
||||
static getSettings = makeEndpointClient('/api/settings', 'get')
|
||||
|
||||
static listGeminiModels = makeEndpointClient('/api/settings/gemini-models', 'get')
|
||||
|
||||
static updateSettings = makeEndpointClient('/api/settings', 'post')
|
||||
|
||||
static suggestGapPrompt = makeEndpointClient('/api/suggest-gap-prompt', 'post', {
|
||||
exactErrorStatuses: [401, 403] as const,
|
||||
})
|
||||
|
||||
static generateVideo = makeEndpointClient('/api/generate', 'post', {
|
||||
exactErrorStatuses: [402] as const,
|
||||
})
|
||||
|
||||
static getGenerateVideoModelSpecs = makeEndpointClient('/api/generate/models-specs', 'get')
|
||||
|
||||
static cancelGeneration = makeEndpointClient('/api/generate/cancel', 'post')
|
||||
|
||||
static getGenerationProgress = makeEndpointClient('/api/generation/progress', 'get')
|
||||
|
||||
static generateImage = makeEndpointClient('/api/generate-image', 'post')
|
||||
|
||||
static enhancePrompt = makeEndpointClient('/api/enhance-prompt', 'post', {
|
||||
exactErrorStatuses: [404, 409] as const,
|
||||
})
|
||||
|
||||
static retake = makeEndpointClient('/api/retake', 'post')
|
||||
|
||||
static extend = makeEndpointClient('/api/extend', 'post')
|
||||
|
||||
static startHuggingFaceLogin = makeEndpointClient('/api/auth/huggingface/login', 'post')
|
||||
|
||||
static getHuggingFaceAuthStatus = makeEndpointClient('/api/auth/huggingface/status', 'get')
|
||||
|
||||
static huggingFaceLogout = makeEndpointClient('/api/auth/huggingface/logout', 'post')
|
||||
|
||||
static checkModelAccess = makeEndpointClient('/api/models/check-access', 'post')
|
||||
|
||||
static generateIcLora = makeEndpointClient('/api/ic-lora/generate', 'post')
|
||||
|
||||
static extractIcLoraConditioning = makeEndpointClient('/api/ic-lora/extract-conditioning', 'post')
|
||||
|
||||
static listIcLoras = makeEndpointClient('/api/ic-loras', 'get')
|
||||
|
||||
static startIcLoraDownload = makeEndpointClient('/api/ic-loras/download', 'post')
|
||||
|
||||
static getIcLoraDownloadProgress(
|
||||
query: QueryFor<'/api/ic-loras/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/ic-loras/download/progress', 'get'>> {
|
||||
const path = `/api/ic-loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/ic-loras/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
static listLoras = makeEndpointClient('/api/loras', 'get')
|
||||
|
||||
static startLoraDownload = makeEndpointClient('/api/loras/download', 'post')
|
||||
|
||||
static getLoraDownloadProgress(
|
||||
query: QueryFor<'/api/loras/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/loras/download/progress', 'get'>> {
|
||||
const path = `/api/loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/loras/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
}
|
||||
|
||||
type ApiClientMethodName = keyof typeof ApiClient
|
||||
|
||||
export type ApiRequestBodyOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (
|
||||
body?: infer TBody,
|
||||
...args: any[]
|
||||
) => Promise<any>
|
||||
? TBody
|
||||
: never
|
||||
|
||||
export type ApiSuccessOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
|
||||
? ApiSuccess<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
|
||||
: never
|
||||
|
||||
export type ApiErrorsOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
|
||||
? ApiErrors<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
|
||||
: never
|
||||
@@ -0,0 +1,56 @@
|
||||
import { logger } from './logger'
|
||||
|
||||
export type ProjectAssetType = 'video' | 'image'
|
||||
|
||||
export interface ProjectAssetCopyResult {
|
||||
path: string
|
||||
bigThumbnailPath: string
|
||||
smallThumbnailPath: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a video/image file to project storage and return precomputed thumbnail paths.
|
||||
*/
|
||||
export async function addVisualAssetToProject(
|
||||
srcPath: string,
|
||||
projectId: string,
|
||||
type: ProjectAssetType,
|
||||
): Promise<ProjectAssetCopyResult | null> {
|
||||
try {
|
||||
const result = await window.electronAPI.addVisualAssetToProject({ srcPath, projectId, type })
|
||||
if (result.success) {
|
||||
return {
|
||||
path: result.path,
|
||||
bigThumbnailPath: result.bigThumbnailPath,
|
||||
smallThumbnailPath: result.smallThumbnailPath,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
}
|
||||
}
|
||||
logger.warn(`Failed to add asset to project folder: ${result.error}`)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to add asset to project folder: ${e}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file to project storage without thumbnail generation (audio path).
|
||||
*/
|
||||
export async function addGenericAssetToProject(
|
||||
srcPath: string,
|
||||
projectId: string,
|
||||
): Promise<{ path: string } | null> {
|
||||
try {
|
||||
const result = await window.electronAPI.addGenericAssetToProject({ srcPath, projectId })
|
||||
if (result.success) {
|
||||
return { path: result.path }
|
||||
}
|
||||
logger.warn(`Failed to copy file to project folder: ${result.error}`)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to copy file to project folder: ${e}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
let cached: { url: string; token: string } | null = null
|
||||
|
||||
export async function getBackendCredentials(): Promise<{ url: string; token: string }> {
|
||||
if (!cached) cached = await window.electronAPI.getBackend()
|
||||
return cached
|
||||
}
|
||||
|
||||
export function resetBackendCredentials(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const { url, token } = await getBackendCredentials()
|
||||
const headers = new Headers(init?.headers)
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(`${url}${path}`, { ...init, headers })
|
||||
}
|
||||
|
||||
export async function backendWsUrl(path: string): Promise<string> {
|
||||
const { url, token } = await getBackendCredentials()
|
||||
const ws = url.replace('http://', 'ws://')
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
return `${ws}${path}${sep}token=${token}`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { DEFAULT_KEYFRAME_STRENGTH, type KeyframeItem } from './multi-keyframe.ts'
|
||||
import { buildGenerateVideoImageInputs } from './build-generate-video-body.ts'
|
||||
|
||||
const keyframes: KeyframeItem[] = [
|
||||
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ id: 'ending', path: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
]
|
||||
|
||||
describe('buildGenerateVideoImageInputs', () => {
|
||||
it('maps multi-keyframes to the backend request shape', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: null,
|
||||
lastImagePath: null,
|
||||
keyframes,
|
||||
}),
|
||||
{
|
||||
keyframes: [
|
||||
{ imagePath: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ imagePath: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('omits image inputs when multi-keyframes are present', () => {
|
||||
const result = buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
keyframes,
|
||||
})
|
||||
|
||||
assert.equal('imagePath' in result, false)
|
||||
assert.equal('lastImagePath' in result, false)
|
||||
assert.equal('keyframes' in result, true)
|
||||
})
|
||||
|
||||
it('forwards each still\'s owned strength on generate', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: null,
|
||||
lastImagePath: null,
|
||||
keyframes: [
|
||||
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: 0.7 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
keyframes: [{ imagePath: '/frames/opening.png', frameIndex: 0, strength: 0.7 }],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves image inputs outside multi-keyframe mode', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'video',
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
keyframes,
|
||||
}),
|
||||
{
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GenSpaceMode } from './genspace-multi-keyframe'
|
||||
import { toKeyframeInputs, type KeyframeItem } from './multi-keyframe.ts'
|
||||
|
||||
interface GenerateVideoImageInputs {
|
||||
imagePath?: string
|
||||
lastImagePath?: string
|
||||
keyframes?: Array<{
|
||||
imagePath: string
|
||||
frameIndex: number
|
||||
strength: number
|
||||
}>
|
||||
}
|
||||
|
||||
export function buildGenerateVideoImageInputs({
|
||||
mode,
|
||||
imagePath,
|
||||
lastImagePath,
|
||||
keyframes,
|
||||
}: {
|
||||
mode: GenSpaceMode
|
||||
imagePath: string | null | undefined
|
||||
lastImagePath: string | null | undefined
|
||||
keyframes: KeyframeItem[]
|
||||
}): GenerateVideoImageInputs {
|
||||
if (mode === 'multi-keyframe' && keyframes.length > 0) {
|
||||
return { keyframes: toKeyframeInputs(keyframes) }
|
||||
}
|
||||
|
||||
return {
|
||||
...(imagePath ? { imagePath } : {}),
|
||||
...(lastImagePath ? { lastImagePath } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
GEMINI_KEY_REQUIRED_SETTINGS_DETAIL,
|
||||
isEnhanceBlockedByMissingGeminiKey,
|
||||
} from './enhance-gemini-key.ts'
|
||||
|
||||
const blocked = {
|
||||
enhanceAvailableForMode: true,
|
||||
enhanceProvider: 'api' as const,
|
||||
hasGeminiApiKey: false,
|
||||
hasEnhanceInput: true,
|
||||
isGenerationInProgressForEnhance: false,
|
||||
isOtherGenerationRunning: false,
|
||||
}
|
||||
|
||||
describe('isEnhanceBlockedByMissingGeminiKey', () => {
|
||||
it('is true when Enhance (API) is selected and no Gemini key is configured', () => {
|
||||
assert.equal(isEnhanceBlockedByMissingGeminiKey(blocked), true)
|
||||
})
|
||||
|
||||
it('is false when enhance is not available for the current mode', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceAvailableForMode: false }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when a Gemini key is already configured', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasGeminiApiKey: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when Enhance is using the local provider, even without a Gemini key', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceProvider: 'local' }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when there is no prompt or image to enhance', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasEnhanceInput: false }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false while this project is already generating', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isGenerationInProgressForEnhance: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false while another project is generating', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isOtherGenerationRunning: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEMINI_KEY_REQUIRED_SETTINGS_DETAIL', () => {
|
||||
it('opens Settings on the API Keys tab for the Gemini key banner', () => {
|
||||
assert.deepEqual(GEMINI_KEY_REQUIRED_SETTINGS_DETAIL, {
|
||||
tab: 'apiKeys',
|
||||
reason: 'geminiKeyRequired',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export const GEMINI_KEY_REQUIRED_SETTINGS_DETAIL = {
|
||||
tab: 'apiKeys' as const,
|
||||
reason: 'geminiKeyRequired' as const,
|
||||
}
|
||||
|
||||
export function isEnhanceBlockedByMissingGeminiKey(input: {
|
||||
enhanceAvailableForMode: boolean
|
||||
enhanceProvider: 'local' | 'api'
|
||||
hasGeminiApiKey: boolean
|
||||
hasEnhanceInput: boolean
|
||||
isGenerationInProgressForEnhance: boolean
|
||||
isOtherGenerationRunning: boolean
|
||||
}): boolean {
|
||||
// True when Enhance would run via Gemini but no key is configured — including when local
|
||||
// Enhance is available and the user explicitly picked API. Clicking then opens Settings
|
||||
// instead of hiding the API option.
|
||||
return (
|
||||
input.enhanceAvailableForMode
|
||||
&& input.enhanceProvider === 'api'
|
||||
&& !input.hasGeminiApiKey
|
||||
&& input.hasEnhanceInput
|
||||
&& !input.isGenerationInProgressForEnhance
|
||||
&& !input.isOtherGenerationRunning
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { mediaUrl } from './web-platform'
|
||||
export function pathToFileUrl(filePath: string): string { return mediaUrl(filePath) }
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { fixedMenuPosition } from './fixed-menu-position.ts'
|
||||
|
||||
const trigger = { left: 100, right: 180, top: 500, bottom: 532 }
|
||||
const viewport = { width: 1000, height: 800 }
|
||||
|
||||
describe('fixedMenuPosition', () => {
|
||||
it('anchors a menu above the trigger', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger,
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 100)
|
||||
assert.equal(pos.bottom, 308)
|
||||
assert.equal(pos.top, undefined)
|
||||
})
|
||||
|
||||
it('anchors a menu below the trigger', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger,
|
||||
placement: 'below',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 100)
|
||||
assert.equal(pos.top, 540)
|
||||
assert.equal(pos.bottom, undefined)
|
||||
})
|
||||
|
||||
it('shifts left so a wide menu stays on-screen', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 820)
|
||||
})
|
||||
|
||||
it('does not use an assumed width when the menu has not been measured', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 900)
|
||||
})
|
||||
|
||||
it('ignores a viewport-filling width from an unpositioned block menu', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 400, right: 480, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 1000,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export type MenuPlacement = 'above' | 'below'
|
||||
|
||||
const EDGE_PX = 8
|
||||
|
||||
export function fixedMenuPosition({
|
||||
trigger,
|
||||
placement,
|
||||
viewport,
|
||||
menuWidth,
|
||||
gap = 8,
|
||||
}: {
|
||||
trigger: { left: number; right: number; top: number; bottom: number }
|
||||
placement: MenuPlacement
|
||||
viewport: { width: number; height: number }
|
||||
menuWidth?: number
|
||||
gap?: number
|
||||
}): { left: number; top?: number; bottom?: number } {
|
||||
let left = trigger.left
|
||||
const measuredWidth =
|
||||
menuWidth != null && menuWidth < viewport.width - EDGE_PX * 2 ? menuWidth : undefined
|
||||
if (measuredWidth != null) {
|
||||
const maxLeft = viewport.width - measuredWidth - EDGE_PX
|
||||
if (left > maxLeft) left = trigger.right - measuredWidth
|
||||
left = Math.max(EDGE_PX, Math.min(left, maxLeft))
|
||||
} else {
|
||||
left = Math.max(EDGE_PX, left)
|
||||
}
|
||||
|
||||
if (placement === 'below') {
|
||||
return { left, top: trigger.bottom + gap }
|
||||
}
|
||||
return { left, bottom: viewport.height - trigger.top + gap }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { formatBytes } from './format.ts'
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats zero', () => {
|
||||
assert.equal(formatBytes(0), '0 B')
|
||||
})
|
||||
|
||||
it('formats megabytes with one decimal', () => {
|
||||
assert.equal(formatBytes(996_000_000), '949.9 MB')
|
||||
})
|
||||
|
||||
it('formats terabytes instead of overflowing the unit list', () => {
|
||||
assert.equal(formatBytes(1.8 * 1024 ** 4), '1.8 TB')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// Human-readable byte size, e.g. 996_000_000 -> "949.9 MB". Binary units (1024).
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
// Local, live "is a generation running?" signal for the renderer UI.
|
||||
// Ref-counted because generations can overlap.
|
||||
let activeCount = 0
|
||||
const listeners = new Set<() => void>()
|
||||
function emit() { for (const l of listeners) l() }
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb)
|
||||
return () => { listeners.delete(cb) }
|
||||
}
|
||||
function getSnapshot(): boolean {
|
||||
return activeCount > 0
|
||||
}
|
||||
|
||||
export function useIsGenerationActive(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
// Local generation can starve the backend's event loop; withGenerationActive tells main so the
|
||||
// liveness monitor doesn't kill a busy backend. We also keep a local count for the UI signal above.
|
||||
export async function withGenerationActive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
activeCount += 1; emit()
|
||||
void window.electronAPI.notifyGenerationActive({ active: true })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1); emit()
|
||||
void window.electronAPI.notifyGenerationActive({ active: false })
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate at job start and freeze — live Settings must not flip Stop mid-request. */
|
||||
export function canCancelLocalJob(
|
||||
kind: 'video' | 'image',
|
||||
videoUsesLtxApi: boolean,
|
||||
imageUsesFalApi: boolean,
|
||||
): boolean {
|
||||
if (kind === 'image') return !imageUsesFalApi
|
||||
if (kind === 'video') return !videoUsesLtxApi
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ApiErrorsOf } from './api-client'
|
||||
|
||||
export type LocalGenerationError = {
|
||||
status: 'default'
|
||||
error: {
|
||||
code: 'LOCAL_GENERATION_ERROR'
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type GenerationError = ApiErrorsOf<'generateVideo'> | ApiErrorsOf<'generateImage'> | LocalGenerationError
|
||||
|
||||
export function createLocalGenerationError(message: string): LocalGenerationError {
|
||||
return {
|
||||
status: 'default',
|
||||
error: {
|
||||
code: 'LOCAL_GENERATION_ERROR',
|
||||
message,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function prepareGenerationInput(inputPath: string | null | undefined): Promise<string | null> {
|
||||
if (!inputPath) return null
|
||||
const result = await window.electronAPI.prepareGenerationInput({ path: inputPath })
|
||||
if (!result.success) throw new Error(result.error)
|
||||
return result.path
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ApiClient } from './api-client'
|
||||
import { GENERATION_RECOVERY_KEY } from '../hooks/use-generation'
|
||||
|
||||
type ProgressResult = Awaited<ReturnType<typeof ApiClient.getGenerationProgress>>
|
||||
type Listener = (result: ProgressResult) => void
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MARKER_CHECK_INTERVAL_MS = 3000
|
||||
|
||||
const listeners = new Set<Listener>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let lastResult: ProgressResult | null = null
|
||||
let pollInFlight = false
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
// getGenerationProgress can be slow under the exact event-loop-starvation this PR exists to
|
||||
// handle — without this guard, a slow response plus this interval's next tick queues a second
|
||||
// overlapping request, and whichever resolves last "wins" regardless of which was actually
|
||||
// more recent, dispatching stale results out of order.
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
try {
|
||||
const result = await ApiClient.getGenerationProgress()
|
||||
lastResult = result
|
||||
listeners.forEach(listener => listener(result))
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
// The global generation lock and the background recovery watcher both need the same
|
||||
// generation-progress poll while mounted together (any project open); sharing one interval
|
||||
// instead of one per subscriber halves that network chatter.
|
||||
export function subscribeToGenerationProgress(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
if (lastResult) listener(lastResult)
|
||||
if (!interval) {
|
||||
void poll()
|
||||
interval = setInterval(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0 && interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
// Otherwise a later generation's fresh subscribe (see subscribeWhileGenerationMayBeActive)
|
||||
// immediately replays this now-unrelated snapshot before its own first poll ever completes
|
||||
// - e.g. reporting "running" from a previous, unrelated session for a moment.
|
||||
lastResult = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every generate-starting call site (see GenSpace.tsx's writeRecoveryContext calls) writes a
|
||||
// recovery marker into localStorage BEFORE it starts, in this same renderer process — so no
|
||||
// marker anywhere is proof nothing here could be running, checkable with a local read instead of
|
||||
// a network call. Gates the shared poll on that: idle app (no generation ever started, or one
|
||||
// that already finished and was consumed) costs zero network calls, not just at startup but for
|
||||
// the whole session, since this re-checks on the same cadence as the poll it gates.
|
||||
export function subscribeWhileGenerationMayBeActive(listener: Listener): () => void {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let latest: ProgressResult | null = null
|
||||
const trackingListener: Listener = result => {
|
||||
latest = result
|
||||
listener(result)
|
||||
}
|
||||
const sync = () => {
|
||||
const hasMarker = localStorage.getItem(GENERATION_RECOVERY_KEY) != null
|
||||
if (hasMarker) {
|
||||
if (!unsubscribe) unsubscribe = subscribeToGenerationProgress(trackingListener)
|
||||
return
|
||||
}
|
||||
// No marker doesn't mean safe to stop yet: a marker's writer can clear it eagerly, straight
|
||||
// off its own HTTP response (Enhance does — see GenSpace.tsx's runEnhance), without ever
|
||||
// going through a poll cycle first. If we unsubscribed immediately here, a shared poll tick
|
||||
// still mid-flight (or one that simply hasn't fired again yet) never gets to redeliver the
|
||||
// real terminal status, and the listener (e.g. useGlobalGenerationLock's isRunning) is stuck
|
||||
// on whatever "running" snapshot it last saw — forever, since nothing calls it again. Only
|
||||
// stop once a poll has actually caught up and confirmed we're not running.
|
||||
if (unsubscribe && latest && (!latest.ok || latest.data.status !== 'running')) {
|
||||
unsubscribe()
|
||||
unsubscribe = null
|
||||
}
|
||||
}
|
||||
sync()
|
||||
const interval = setInterval(sync, MARKER_CHECK_INTERVAL_MS)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
unsubscribe?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { addVisualAssetToProject } from './asset-copy'
|
||||
import { toModelsDirRelativeRef } from './lora-library'
|
||||
import { logger } from './logger'
|
||||
import type { RecoveryGenType, RecoveryImporter } from './generation-recovery'
|
||||
import { videoGenerationModeFromInputs } from './multi-keyframe'
|
||||
|
||||
// Mirrors GenSpace's own live-generation completion effect (see the `videoPath` effect in
|
||||
// GenSpace.tsx) but operates on the recovery marker's captured context instead of live component
|
||||
// state, so it can run in the background while that project's GenSpace isn't mounted. ic-lora/
|
||||
// retake/extend write a marker with no full `settings` (see GenerationRecoveryContext) and
|
||||
// recover as a standalone video asset here too; retake/extend additionally set `ctx.model` so
|
||||
// the pipeline label isn't forced to 'fast'.
|
||||
const importVideo: RecoveryImporter = async (ctx, result, { addAsset, modelsDir }) => {
|
||||
const videoPath = typeof result === 'string' ? result : result[0]
|
||||
if (!videoPath) return
|
||||
|
||||
const copied = await addVisualAssetToProject(videoPath, ctx.projectId, 'video')
|
||||
if (!copied) throw new Error('Could not persist generated video to project storage')
|
||||
|
||||
const s = ctx.settings
|
||||
const genMode = videoGenerationModeFromInputs({
|
||||
keyframes: ctx.keyframes,
|
||||
audioUrl: ctx.inputAudioUrl,
|
||||
imageUrl: ctx.inputImageUrl,
|
||||
})
|
||||
|
||||
addAsset(ctx.projectId, {
|
||||
type: 'video',
|
||||
path: copied.path,
|
||||
bigThumbnailPath: copied.bigThumbnailPath,
|
||||
smallThumbnailPath: copied.smallThumbnailPath,
|
||||
width: copied.width,
|
||||
height: copied.height,
|
||||
prompt: ctx.prompt,
|
||||
resolution: s?.videoResolution ?? '',
|
||||
duration: s?.duration ?? undefined,
|
||||
generationParams: {
|
||||
mode: genMode,
|
||||
prompt: ctx.prompt,
|
||||
model: ctx.model ?? s?.model ?? 'fast',
|
||||
modelLabel: ctx.modelLabel,
|
||||
duration: s?.duration ?? null,
|
||||
resolution: s?.videoResolution ?? '',
|
||||
fps: s?.fps ?? 24,
|
||||
audio: s?.audio ?? false,
|
||||
cameraMotion: 'none',
|
||||
imageAspectRatio: s?.aspectRatio,
|
||||
imageSteps: 4,
|
||||
inputImageUrl: ctx.inputImageUrl,
|
||||
inputLastImageUrl: ctx.inputLastImageUrl,
|
||||
inputAudioUrl: ctx.inputAudioUrl,
|
||||
keyframes: ctx.keyframes && ctx.keyframes.length > 0 ? ctx.keyframes : undefined,
|
||||
loras: s?.loras && s.loras.length > 0
|
||||
? s.loras.map(l => ({ ref: toModelsDirRelativeRef(l.ref, modelsDir), name: l.name, scale: l.scale }))
|
||||
: undefined,
|
||||
},
|
||||
takes: [{
|
||||
path: copied.path,
|
||||
bigThumbnailPath: copied.bigThumbnailPath,
|
||||
smallThumbnailPath: copied.smallThumbnailPath,
|
||||
width: copied.width,
|
||||
height: copied.height,
|
||||
createdAt: Date.now(),
|
||||
}],
|
||||
activeTakeIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors GenSpace's `imagePaths` completion effect. A failed copy for one image in a multi-image
|
||||
// batch is logged and skipped rather than thrown, same as the live effect — one bad file must not
|
||||
// drop the rest of the batch. But if *every* copy in the batch fails, this must throw rather than
|
||||
// return normally: checkAndConsumeRecovery treats a clean return as "imported, delete the
|
||||
// marker" — silently succeeding on a total failure would drop the result for good.
|
||||
const importImage: RecoveryImporter = async (ctx, result, { addAsset }) => {
|
||||
const paths = Array.isArray(result) ? result : [result]
|
||||
const s = ctx.settings
|
||||
const genMode = ctx.inputImageUrl ? 'image-edit' : 'text-to-image'
|
||||
let importedAny = false
|
||||
|
||||
for (const imgPath of paths) {
|
||||
const copied = await addVisualAssetToProject(imgPath, ctx.projectId, 'image')
|
||||
if (!copied) {
|
||||
logger.error(`Could not persist generated image to project storage: ${imgPath}`)
|
||||
continue
|
||||
}
|
||||
importedAny = true
|
||||
addAsset(ctx.projectId, {
|
||||
type: 'image',
|
||||
path: copied.path,
|
||||
bigThumbnailPath: copied.bigThumbnailPath,
|
||||
smallThumbnailPath: copied.smallThumbnailPath,
|
||||
width: copied.width,
|
||||
height: copied.height,
|
||||
prompt: ctx.prompt,
|
||||
resolution: s?.imageResolution ?? '',
|
||||
generationParams: {
|
||||
mode: genMode,
|
||||
prompt: ctx.prompt,
|
||||
model: 'fast',
|
||||
duration: 5,
|
||||
resolution: s?.imageResolution ?? '',
|
||||
fps: 24,
|
||||
audio: false,
|
||||
cameraMotion: 'none',
|
||||
imageAspectRatio: s?.aspectRatio,
|
||||
imageSteps: s?.imageSteps ?? 4,
|
||||
...(ctx.inputImageUrl ? { inputImageUrl: ctx.inputImageUrl, imageEditStrength: s?.imageEditStrength } : {}),
|
||||
},
|
||||
takes: [{
|
||||
path: copied.path,
|
||||
bigThumbnailPath: copied.bigThumbnailPath,
|
||||
smallThumbnailPath: copied.smallThumbnailPath,
|
||||
width: copied.width,
|
||||
height: copied.height,
|
||||
createdAt: Date.now(),
|
||||
}],
|
||||
activeTakeIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (!importedAny && paths.length > 0) {
|
||||
throw new Error('Could not persist any generated image to project storage')
|
||||
}
|
||||
}
|
||||
|
||||
export const builtinRecoveryImporters: Partial<Record<RecoveryGenType, RecoveryImporter>> = {
|
||||
video: importVideo,
|
||||
image: importImage,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ApiClient } from './api-client'
|
||||
import { GENERATION_RECOVERY_KEY, type GenerationRecoveryContext } from '../hooks/use-generation'
|
||||
import { builtinRecoveryImporters } from './generation-recovery-importers'
|
||||
import type { Asset } from '../types/project-model'
|
||||
|
||||
// Keyed the same way the recovery marker already is: undefined means the default "video" case
|
||||
// (t2v/i2v/a2v/ic-lora/retake/extend all recover as a standalone video asset today — see
|
||||
// GenSpace's own mount-recovery effect for why those four share one fallback).
|
||||
export type RecoveryGenType = NonNullable<GenerationRecoveryContext['genType']> | 'video'
|
||||
|
||||
export interface RecoveryImporterApi {
|
||||
addAsset: (projectId: string, asset: Omit<Asset, 'id' | 'createdAt'>) => unknown
|
||||
modelsDir: string
|
||||
}
|
||||
|
||||
export type RecoveryImporter = (
|
||||
ctx: GenerationRecoveryContext,
|
||||
result: string | string[],
|
||||
api: RecoveryImporterApi,
|
||||
) => Promise<void> | void
|
||||
|
||||
// A marker written by an older build (before baselineId existed) would parse fine as JSON but
|
||||
// have `baselineId === undefined` — and since the progress endpoint's `id` is always `string |
|
||||
// null`, never `undefined`, an identity check that just compares `observedId === ctx.baselineId`
|
||||
// would treat that `undefined` as "already different from whatever's live right now" and trust
|
||||
// it immediately, on the very first tick, with zero confirmation. Callers must check this before
|
||||
// trusting anything else in the marker.
|
||||
export function hasValidBaselineId(ctx: GenerationRecoveryContext): boolean {
|
||||
return typeof ctx.baselineId === 'string' || ctx.baselineId === null
|
||||
}
|
||||
|
||||
// The project whose GenSpace instance is currently mounted and already handling its own
|
||||
// generation lifecycle live (polling, completion effects). The background watcher backs off
|
||||
// entirely for it, so two independent pollers never race to import the same completion twice.
|
||||
let activeOwnerProjectId: string | null = null
|
||||
|
||||
export function setActiveGenerationOwner(projectId: string | null): void {
|
||||
activeOwnerProjectId = projectId
|
||||
}
|
||||
|
||||
// One check: is there a recovery marker, is anything registered to handle it, and if the
|
||||
// generation it points at has finished, persist the result into its project. Takes an
|
||||
// already-fetched progress poll (shared with useGlobalGenerationLock via
|
||||
// subscribeToGenerationProgress) instead of fetching its own, so mounting both doesn't double
|
||||
// the network chatter.
|
||||
export async function checkAndConsumeRecovery(
|
||||
progress: Awaited<ReturnType<typeof ApiClient.getGenerationProgress>>,
|
||||
api: RecoveryImporterApi,
|
||||
): Promise<void> {
|
||||
const saved = localStorage.getItem(GENERATION_RECOVERY_KEY)
|
||||
if (!saved) return
|
||||
|
||||
let ctx: GenerationRecoveryContext
|
||||
try {
|
||||
ctx = JSON.parse(saved) as GenerationRecoveryContext
|
||||
} catch {
|
||||
localStorage.removeItem(GENERATION_RECOVERY_KEY)
|
||||
return
|
||||
}
|
||||
if (!hasValidBaselineId(ctx)) {
|
||||
localStorage.removeItem(GENERATION_RECOVERY_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// That project's own GenSpace is mounted and already polling/importing this live.
|
||||
if (ctx.projectId === activeOwnerProjectId) return
|
||||
|
||||
// A generation kind with no importer (e.g. 'enhance': there's nowhere to put a rewritten
|
||||
// prompt without an open editor) is left alone here — only that project's own mount-recovery
|
||||
// effect can handle it.
|
||||
const importer = builtinRecoveryImporters[ctx.genType ?? 'video']
|
||||
if (!importer) return
|
||||
|
||||
if (!progress.ok) return
|
||||
const observedId = progress.data.id
|
||||
const status = progress.data.status
|
||||
|
||||
if (ctx.generationId == null) {
|
||||
// Not yet confirmed. Any id different from the baseline captured when this marker was
|
||||
// written proves (single global generation slot) our generation has started — regardless of
|
||||
// status, even if it's already 'complete' by the time we look (a fast generation can finish
|
||||
// between two polls). Until the id actually changes, this endpoint is still reporting
|
||||
// whatever predated this marker, which must not be trusted.
|
||||
if (observedId === ctx.baselineId) return
|
||||
ctx = { ...ctx, generationId: observedId ?? undefined }
|
||||
localStorage.setItem(GENERATION_RECOVERY_KEY, JSON.stringify(ctx))
|
||||
} else if (observedId !== ctx.generationId) {
|
||||
// Already confirmed once; a FURTHER id change means a different generation superseded ours
|
||||
// before we ever saw it finish. Nothing left to recover.
|
||||
localStorage.removeItem(GENERATION_RECOVERY_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'running') return // still going — check again next tick
|
||||
|
||||
if (status === 'complete' && progress.data.result != null) {
|
||||
try {
|
||||
await importer(ctx, progress.data.result, api)
|
||||
} catch {
|
||||
// Leave the marker in place so the next tick (or that project's own mount effect) can
|
||||
// retry — a failed copy/import must not silently drop the result.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GENERATION_RECOVERY_KEY)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Asset } from '../types/project-model'
|
||||
|
||||
export type GenSpaceTypeFilter = 'all' | 'video' | 'image'
|
||||
export type GenSpaceSortKey = 'createdAt' | 'type' | 'duration' | 'resolution' | 'ratio'
|
||||
export type GenSpaceSortDir = 'asc' | 'desc'
|
||||
|
||||
export const GENSPACE_TYPE_FILTER_OPTIONS: { value: GenSpaceTypeFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'video', label: 'Videos' },
|
||||
{ value: 'image', label: 'Images' },
|
||||
]
|
||||
|
||||
export const GENSPACE_SORT_OPTIONS: { value: GenSpaceSortKey; label: string }[] = [
|
||||
{ value: 'createdAt', label: 'Date' },
|
||||
{ value: 'type', label: 'Type' },
|
||||
{ value: 'duration', label: 'Duration' },
|
||||
{ value: 'resolution', label: 'Resolution' },
|
||||
{ value: 'ratio', label: 'Ratio' },
|
||||
]
|
||||
|
||||
const VISUAL_TYPES = new Set(['video', 'image'])
|
||||
|
||||
export function defaultSortDir(key: GenSpaceSortKey): GenSpaceSortDir {
|
||||
switch (key) {
|
||||
case 'createdAt':
|
||||
case 'duration':
|
||||
case 'resolution':
|
||||
case 'ratio':
|
||||
case 'type':
|
||||
return 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
export function filterGenSpaceAssets(
|
||||
assets: Asset[],
|
||||
typeFilter: GenSpaceTypeFilter,
|
||||
favoritesOnly: boolean,
|
||||
): Asset[] {
|
||||
let result = assets.filter(asset => VISUAL_TYPES.has(asset.type))
|
||||
if (typeFilter !== 'all') {
|
||||
result = result.filter(asset => asset.type === typeFilter)
|
||||
}
|
||||
if (favoritesOnly) {
|
||||
result = result.filter(asset => asset.favorite)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function resolutionRank(asset: Asset): number {
|
||||
if (asset.width && asset.height) return Math.min(asset.width, asset.height)
|
||||
const match = asset.resolution?.match(/(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : 0
|
||||
}
|
||||
|
||||
function durationRank(asset: Asset): number {
|
||||
return asset.duration ?? 0
|
||||
}
|
||||
|
||||
function parseAspectRatioString(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/)
|
||||
if (!match) return 0
|
||||
const height = Number(match[2])
|
||||
return height === 0 ? 0 : Number(match[1]) / height
|
||||
}
|
||||
|
||||
function ratioRank(asset: Asset): number {
|
||||
if (asset.width && asset.height) return asset.width / asset.height
|
||||
return parseAspectRatioString(asset.generationParams?.imageAspectRatio)
|
||||
}
|
||||
|
||||
function compareTieBreak(a: Asset, b: Asset): number {
|
||||
if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
function comparePrimary(a: Asset, b: Asset, key: GenSpaceSortKey): number {
|
||||
switch (key) {
|
||||
case 'type':
|
||||
return a.type.localeCompare(b.type)
|
||||
case 'duration':
|
||||
return durationRank(a) - durationRank(b)
|
||||
case 'resolution':
|
||||
return resolutionRank(a) - resolutionRank(b)
|
||||
case 'ratio':
|
||||
return ratioRank(a) - ratioRank(b)
|
||||
case 'createdAt':
|
||||
default:
|
||||
return a.createdAt - b.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
export function sortGenSpaceAssets(
|
||||
assets: Asset[],
|
||||
key: GenSpaceSortKey,
|
||||
direction: GenSpaceSortDir,
|
||||
): Asset[] {
|
||||
const dir = direction === 'desc' ? -1 : 1
|
||||
return [...assets].sort((a, b) => {
|
||||
const primary = comparePrimary(a, b, key)
|
||||
if (primary !== 0) return dir * primary
|
||||
return compareTieBreak(a, b)
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldShowGeneratingTile(
|
||||
typeFilter: GenSpaceTypeFilter,
|
||||
mode: 'image' | 'video',
|
||||
): boolean {
|
||||
if (typeFilter === 'all') return true
|
||||
return typeFilter === mode
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { shouldShowLastFrameChip } from './genspace-last-frame.ts'
|
||||
|
||||
describe('shouldShowLastFrameChip', () => {
|
||||
it('is hidden without a first frame', () => {
|
||||
assert.equal(
|
||||
shouldShowLastFrameChip({ mode: 'video', hasFirstFrame: false, duration: 5 }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is hidden when duration is auto', () => {
|
||||
assert.equal(
|
||||
shouldShowLastFrameChip({ mode: 'video', hasFirstFrame: true, duration: null }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is hidden outside video mode', () => {
|
||||
assert.equal(
|
||||
shouldShowLastFrameChip({ mode: 'image', hasFirstFrame: true, duration: 5 }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is shown for video with a first frame and a concrete duration', () => {
|
||||
assert.equal(
|
||||
shouldShowLastFrameChip({ mode: 'video', hasFirstFrame: true, duration: 5 }),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export function shouldShowLastFrameChip(params: {
|
||||
mode: string
|
||||
hasFirstFrame: boolean
|
||||
duration: number | null
|
||||
}): boolean {
|
||||
return params.mode === 'video' && params.hasFirstFrame && params.duration != null
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { beforeEach, describe, it } from 'node:test'
|
||||
import {
|
||||
DEFAULT_PROMPT_BAR_HEIGHT,
|
||||
GENSPACE_LAYOUT_STORAGE_KEY,
|
||||
PROMPT_BAR_HEIGHT_LIMITS,
|
||||
loadPromptBarHeight,
|
||||
savePromptBarHeight,
|
||||
} from './genspace-layout.ts'
|
||||
|
||||
function createMemoryStorage(initial: Record<string, string> = {}) {
|
||||
const data = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('genspace prompt-bar layout', () => {
|
||||
let storage: ReturnType<typeof createMemoryStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMemoryStorage()
|
||||
})
|
||||
|
||||
it('loads the default height when nothing is stored', () => {
|
||||
assert.equal(loadPromptBarHeight(storage), DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
})
|
||||
|
||||
it('round-trips a saved height', () => {
|
||||
savePromptBarHeight(240, storage)
|
||||
assert.equal(loadPromptBarHeight(storage), 240)
|
||||
assert.equal(
|
||||
storage.getItem(GENSPACE_LAYOUT_STORAGE_KEY),
|
||||
JSON.stringify({ promptBarHeight: 240 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps heights outside the allowed range', () => {
|
||||
savePromptBarHeight(PROMPT_BAR_HEIGHT_LIMITS.min - 50, storage)
|
||||
assert.equal(loadPromptBarHeight(storage), PROMPT_BAR_HEIGHT_LIMITS.min)
|
||||
|
||||
savePromptBarHeight(PROMPT_BAR_HEIGHT_LIMITS.max + 80, storage)
|
||||
assert.equal(loadPromptBarHeight(storage), PROMPT_BAR_HEIGHT_LIMITS.max)
|
||||
})
|
||||
|
||||
it('falls back to the default when stored JSON is invalid', () => {
|
||||
storage.setItem(GENSPACE_LAYOUT_STORAGE_KEY, '{not-json')
|
||||
assert.equal(loadPromptBarHeight(storage), DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
})
|
||||
|
||||
it('falls back to the default when promptBarHeight is missing or not a number', () => {
|
||||
storage.setItem(GENSPACE_LAYOUT_STORAGE_KEY, JSON.stringify({}))
|
||||
assert.equal(loadPromptBarHeight(storage), DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
|
||||
storage.setItem(GENSPACE_LAYOUT_STORAGE_KEY, JSON.stringify({ promptBarHeight: 'tall' }))
|
||||
assert.equal(loadPromptBarHeight(storage), DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
export const GENSPACE_LAYOUT_STORAGE_KEY = 'ltx-genspace-layout'
|
||||
|
||||
export const DEFAULT_PROMPT_BAR_HEIGHT = 160
|
||||
|
||||
export const PROMPT_BAR_HEIGHT_LIMITS = { min: 140, max: 400 } as const
|
||||
|
||||
export type LayoutStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
export function clampPromptBarHeight(height: number): number {
|
||||
return Math.max(
|
||||
PROMPT_BAR_HEIGHT_LIMITS.min,
|
||||
Math.min(PROMPT_BAR_HEIGHT_LIMITS.max, Math.round(height)),
|
||||
)
|
||||
}
|
||||
|
||||
export function loadPromptBarHeight(storage: LayoutStorage = globalThis.localStorage): number {
|
||||
try {
|
||||
const stored = storage?.getItem(GENSPACE_LAYOUT_STORAGE_KEY)
|
||||
if (!stored) return DEFAULT_PROMPT_BAR_HEIGHT
|
||||
const parsed = JSON.parse(stored) as { promptBarHeight?: unknown }
|
||||
if (typeof parsed.promptBarHeight !== 'number' || !Number.isFinite(parsed.promptBarHeight)) {
|
||||
return DEFAULT_PROMPT_BAR_HEIGHT
|
||||
}
|
||||
return clampPromptBarHeight(parsed.promptBarHeight)
|
||||
} catch {
|
||||
return DEFAULT_PROMPT_BAR_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
export function savePromptBarHeight(height: number, storage: LayoutStorage = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.setItem(
|
||||
GENSPACE_LAYOUT_STORAGE_KEY,
|
||||
JSON.stringify({ promptBarHeight: clampPromptBarHeight(height) }),
|
||||
)
|
||||
} catch {
|
||||
// Private mode / quota — layout just won't persist.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
autoDurationOptionVisible,
|
||||
canUseMultiKeyframeMode,
|
||||
fallbackGenSpaceMode,
|
||||
genSpaceUsesAudioInput,
|
||||
isGenSpaceLibraryMode,
|
||||
isEnhanceAvailableForMode,
|
||||
modeAfterCompletedGeneration,
|
||||
modeOptionValues,
|
||||
} from './genspace-multi-keyframe.ts'
|
||||
|
||||
const allModesAvailable = {
|
||||
canUseMultiKeyframe: true,
|
||||
canUseRetake: true,
|
||||
canUseExtend: true,
|
||||
canUseIcLora: true,
|
||||
}
|
||||
|
||||
describe('autoDurationOptionVisible', () => {
|
||||
it('hides Auto in multi-keyframe mode even when the model supports it', () => {
|
||||
assert.equal(autoDurationOptionVisible('multi-keyframe', true), false)
|
||||
})
|
||||
|
||||
it('shows Auto in video mode when the model supports it', () => {
|
||||
assert.equal(autoDurationOptionVisible('video', true), true)
|
||||
})
|
||||
|
||||
it('hides Auto when the model does not support it', () => {
|
||||
assert.equal(autoDurationOptionVisible('video', false), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canUseMultiKeyframeMode', () => {
|
||||
it('is false in API mode even when the local offering supports it', () => {
|
||||
assert.equal(
|
||||
canUseMultiKeyframeMode({
|
||||
isLocalMode: false,
|
||||
localCaps: { multi_keyframe: true },
|
||||
enableMultipleKeyframesVideos: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false in local mode when the capability is missing', () => {
|
||||
assert.equal(
|
||||
canUseMultiKeyframeMode({
|
||||
isLocalMode: true,
|
||||
localCaps: {},
|
||||
enableMultipleKeyframesVideos: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false in local mode when the capability is disabled', () => {
|
||||
assert.equal(
|
||||
canUseMultiKeyframeMode({
|
||||
isLocalMode: true,
|
||||
localCaps: { multi_keyframe: false },
|
||||
enableMultipleKeyframesVideos: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when the Dev Panel flag is off, even in local mode with the capability', () => {
|
||||
assert.equal(
|
||||
canUseMultiKeyframeMode({
|
||||
isLocalMode: true,
|
||||
localCaps: { multi_keyframe: true },
|
||||
enableMultipleKeyframesVideos: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is true only in local mode when the capability and Dev Panel flag are enabled', () => {
|
||||
assert.equal(
|
||||
canUseMultiKeyframeMode({
|
||||
isLocalMode: true,
|
||||
localCaps: { multi_keyframe: true },
|
||||
enableMultipleKeyframesVideos: true,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallbackGenSpaceMode', () => {
|
||||
it('falls back from multi-keyframe when unavailable', () => {
|
||||
assert.equal(
|
||||
fallbackGenSpaceMode('multi-keyframe', {
|
||||
...allModesAvailable,
|
||||
canUseMultiKeyframe: false,
|
||||
}),
|
||||
'video',
|
||||
)
|
||||
})
|
||||
|
||||
for (const [mode, flag] of [
|
||||
['retake', 'canUseRetake'],
|
||||
['extend', 'canUseExtend'],
|
||||
['ic-lora', 'canUseIcLora'],
|
||||
] as const) {
|
||||
it(`falls back from ${mode} when unavailable`, () => {
|
||||
assert.equal(
|
||||
fallbackGenSpaceMode(mode, {
|
||||
...allModesAvailable,
|
||||
[flag]: false,
|
||||
}),
|
||||
'video',
|
||||
)
|
||||
})
|
||||
|
||||
it(`keeps ${mode} when available`, () => {
|
||||
assert.equal(fallbackGenSpaceMode(mode, allModesAvailable), mode)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('modeOptionValues', () => {
|
||||
it('excludes multi-keyframe when unavailable', () => {
|
||||
const values = modeOptionValues({
|
||||
...allModesAvailable,
|
||||
canUseMultiKeyframe: false,
|
||||
})
|
||||
|
||||
assert.equal(values.includes('multi-keyframe'), false)
|
||||
})
|
||||
|
||||
it('includes multi-keyframe immediately after video when available', () => {
|
||||
const values = modeOptionValues(allModesAvailable)
|
||||
|
||||
assert.deepEqual(values.slice(0, 3), ['image', 'video', 'multi-keyframe'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGenSpaceLibraryMode', () => {
|
||||
it('shows the asset library for image and video', () => {
|
||||
assert.equal(isGenSpaceLibraryMode('image'), true)
|
||||
assert.equal(isGenSpaceLibraryMode('video'), true)
|
||||
})
|
||||
|
||||
it('hides the asset library in tool modes, matching retake and extend', () => {
|
||||
assert.equal(isGenSpaceLibraryMode('multi-keyframe'), false)
|
||||
assert.equal(isGenSpaceLibraryMode('retake'), false)
|
||||
assert.equal(isGenSpaceLibraryMode('extend'), false)
|
||||
assert.equal(isGenSpaceLibraryMode('ic-lora'), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modeAfterCompletedGeneration', () => {
|
||||
it('returns to video gen space after a multi-keyframe job', () => {
|
||||
assert.equal(modeAfterCompletedGeneration('multi-keyframe'), 'video')
|
||||
})
|
||||
|
||||
it('does not force a mode change for ordinary video jobs', () => {
|
||||
assert.equal(modeAfterCompletedGeneration('text-to-video'), null)
|
||||
assert.equal(modeAfterCompletedGeneration('image-to-video'), null)
|
||||
assert.equal(modeAfterCompletedGeneration('audio-to-video'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEnhanceAvailableForMode', () => {
|
||||
it('includes multi-keyframe alongside video, image, and ic-lora', () => {
|
||||
assert.equal(isEnhanceAvailableForMode('multi-keyframe'), true)
|
||||
assert.equal(isEnhanceAvailableForMode('video'), true)
|
||||
assert.equal(isEnhanceAvailableForMode('image'), true)
|
||||
assert.equal(isEnhanceAvailableForMode('ic-lora'), true)
|
||||
})
|
||||
|
||||
it('hides Enhance in retake and extend', () => {
|
||||
assert.equal(isEnhanceAvailableForMode('retake'), false)
|
||||
assert.equal(isEnhanceAvailableForMode('extend'), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('genSpaceUsesAudioInput', () => {
|
||||
it('is only video mode, so leftover A2V audio cannot ride along with keyframes', () => {
|
||||
assert.equal(genSpaceUsesAudioInput('video'), true)
|
||||
assert.equal(genSpaceUsesAudioInput('multi-keyframe'), false)
|
||||
assert.equal(genSpaceUsesAudioInput('image'), false)
|
||||
assert.equal(genSpaceUsesAudioInput('retake'), false)
|
||||
assert.equal(genSpaceUsesAudioInput('extend'), false)
|
||||
assert.equal(genSpaceUsesAudioInput('ic-lora'), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
export type GenSpaceMode =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'multi-keyframe'
|
||||
| 'retake'
|
||||
| 'extend'
|
||||
| 'ic-lora'
|
||||
|
||||
export function autoDurationOptionVisible(
|
||||
mode: GenSpaceMode,
|
||||
autoDurationAvailable: boolean,
|
||||
): boolean {
|
||||
return mode !== 'multi-keyframe' && autoDurationAvailable
|
||||
}
|
||||
|
||||
type MultiKeyframeCapabilities = {
|
||||
multi_keyframe?: boolean
|
||||
}
|
||||
|
||||
type ModeAvailability = {
|
||||
canUseMultiKeyframe: boolean
|
||||
canUseRetake: boolean
|
||||
canUseExtend: boolean
|
||||
canUseIcLora: boolean
|
||||
}
|
||||
|
||||
export function canUseMultiKeyframeMode({
|
||||
isLocalMode,
|
||||
localCaps,
|
||||
enableMultipleKeyframesVideos,
|
||||
}: {
|
||||
isLocalMode: boolean
|
||||
localCaps?: MultiKeyframeCapabilities | null
|
||||
enableMultipleKeyframesVideos: boolean
|
||||
}): boolean {
|
||||
return enableMultipleKeyframesVideos && isLocalMode && Boolean(localCaps?.multi_keyframe)
|
||||
}
|
||||
|
||||
export function fallbackGenSpaceMode(
|
||||
mode: GenSpaceMode,
|
||||
availability: ModeAvailability,
|
||||
): GenSpaceMode {
|
||||
if (mode === 'multi-keyframe' && !availability.canUseMultiKeyframe) return 'video'
|
||||
if (mode === 'retake' && !availability.canUseRetake) return 'video'
|
||||
if (mode === 'extend' && !availability.canUseExtend) return 'video'
|
||||
if (mode === 'ic-lora' && !availability.canUseIcLora) return 'video'
|
||||
return mode
|
||||
}
|
||||
|
||||
export function modeOptionValues({
|
||||
canUseMultiKeyframe,
|
||||
canUseRetake,
|
||||
canUseExtend,
|
||||
canUseIcLora,
|
||||
}: ModeAvailability): GenSpaceMode[] {
|
||||
return [
|
||||
'image',
|
||||
'video',
|
||||
...(canUseMultiKeyframe ? (['multi-keyframe'] as const) : []),
|
||||
...(canUseRetake ? (['retake'] as const) : []),
|
||||
...(canUseExtend ? (['extend'] as const) : []),
|
||||
...(canUseIcLora ? (['ic-lora'] as const) : []),
|
||||
]
|
||||
}
|
||||
|
||||
export function isGenSpaceLibraryMode(mode: GenSpaceMode): boolean {
|
||||
return mode === 'video' || mode === 'image'
|
||||
}
|
||||
|
||||
export function modeAfterCompletedGeneration(generationMode: string): GenSpaceMode | null {
|
||||
return generationMode === 'multi-keyframe' ? 'video' : null
|
||||
}
|
||||
|
||||
export function isEnhanceAvailableForMode(mode: GenSpaceMode): boolean {
|
||||
return mode === 'video' || mode === 'ic-lora' || mode === 'image' || mode === 'multi-keyframe'
|
||||
}
|
||||
|
||||
/** Multi-keyframe cannot combine with A2V — leftover audio must not pick A2V envelopes or go on the wire. */
|
||||
export function genSpaceUsesAudioInput(mode: GenSpaceMode): boolean {
|
||||
return mode === 'video'
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// Keyboard Shortcuts system — action registry, keybinding types, preset layouts
|
||||
|
||||
// ── Action IDs ──
|
||||
// Every bindable action in the editor
|
||||
export type ActionId =
|
||||
// Tools
|
||||
| 'tool.select'
|
||||
| 'tool.blade'
|
||||
| 'tool.ripple'
|
||||
| 'tool.roll'
|
||||
| 'tool.slide'
|
||||
| 'tool.slip'
|
||||
| 'tool.trackForward'
|
||||
// Transport
|
||||
| 'transport.playPause'
|
||||
| 'transport.stop'
|
||||
| 'transport.shuttleReverse' // J
|
||||
| 'transport.shuttleStop' // K
|
||||
| 'transport.shuttleForward' // L
|
||||
| 'transport.stepBackward' // Left arrow
|
||||
| 'transport.stepForward' // Right arrow
|
||||
| 'transport.jumpBackward' // Shift+Left
|
||||
| 'transport.jumpForward' // Shift+Right
|
||||
| 'transport.goToStart'
|
||||
| 'transport.goToEnd'
|
||||
| 'transport.goToIn'
|
||||
| 'transport.goToOut'
|
||||
// Editing
|
||||
| 'edit.undo'
|
||||
| 'edit.redo'
|
||||
| 'edit.cut'
|
||||
| 'edit.copy'
|
||||
| 'edit.paste'
|
||||
| 'edit.delete'
|
||||
| 'edit.selectAll'
|
||||
| 'edit.deselect'
|
||||
// Marking
|
||||
| 'mark.setIn'
|
||||
| 'mark.setOut'
|
||||
| 'mark.clearIn'
|
||||
| 'mark.clearOut'
|
||||
| 'mark.clearInOut'
|
||||
// 3-Point Editing
|
||||
| 'edit.insertEdit'
|
||||
| 'edit.overwriteEdit'
|
||||
| 'edit.matchFrame'
|
||||
// Timeline
|
||||
| 'timeline.zoomIn'
|
||||
| 'timeline.zoomOut'
|
||||
| 'timeline.fitToView'
|
||||
| 'timeline.toggleSnap'
|
||||
// Navigation
|
||||
| 'nav.prevEdit'
|
||||
| 'nav.nextEdit'
|
||||
// View
|
||||
| 'view.fullscreen'
|
||||
|
||||
// ── Key Combo ──
|
||||
export interface KeyCombo {
|
||||
key: string // e.g. 'b', ' ', 'arrowleft', 'delete', ','
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
meta?: boolean // Cmd on Mac
|
||||
}
|
||||
|
||||
// ── Action Definition ──
|
||||
export interface ActionDefinition {
|
||||
id: ActionId
|
||||
label: string
|
||||
category: 'Tools' | 'Transport' | 'Editing' | 'Marking' | 'Timeline'
|
||||
description?: string
|
||||
}
|
||||
|
||||
// ── Full Registry ──
|
||||
export const ACTION_REGISTRY: ActionDefinition[] = [
|
||||
// Tools
|
||||
{ id: 'tool.select', label: 'Selection Tool', category: 'Tools' },
|
||||
{ id: 'tool.blade', label: 'Blade / Razor Tool', category: 'Tools' },
|
||||
{ id: 'tool.ripple', label: 'Ripple Edit Tool', category: 'Tools' },
|
||||
{ id: 'tool.roll', label: 'Roll Edit Tool', category: 'Tools' },
|
||||
{ id: 'tool.slide', label: 'Slide Tool', category: 'Tools' },
|
||||
{ id: 'tool.slip', label: 'Slip Tool', category: 'Tools' },
|
||||
{ id: 'tool.trackForward', label: 'Track Select Forward', category: 'Tools' },
|
||||
// Transport
|
||||
{ id: 'transport.playPause', label: 'Play / Pause', category: 'Transport' },
|
||||
{ id: 'transport.stop', label: 'Stop', category: 'Transport' },
|
||||
{ id: 'transport.shuttleReverse', label: 'Shuttle Reverse (J)', category: 'Transport' },
|
||||
{ id: 'transport.shuttleStop', label: 'Shuttle Stop (K)', category: 'Transport' },
|
||||
{ id: 'transport.shuttleForward', label: 'Shuttle Forward (L)', category: 'Transport' },
|
||||
{ id: 'transport.stepBackward', label: 'Step Backward (1 frame)', category: 'Transport' },
|
||||
{ id: 'transport.stepForward', label: 'Step Forward (1 frame)', category: 'Transport' },
|
||||
{ id: 'transport.jumpBackward', label: 'Jump Backward (1 sec)', category: 'Transport' },
|
||||
{ id: 'transport.jumpForward', label: 'Jump Forward (1 sec)', category: 'Transport' },
|
||||
{ id: 'transport.goToStart', label: 'Go to Start', category: 'Transport' },
|
||||
{ id: 'transport.goToEnd', label: 'Go to End', category: 'Transport' },
|
||||
{ id: 'transport.goToIn', label: 'Go to In Point', category: 'Transport' },
|
||||
{ id: 'transport.goToOut', label: 'Go to Out Point', category: 'Transport' },
|
||||
// Editing
|
||||
{ id: 'edit.undo', label: 'Undo', category: 'Editing' },
|
||||
{ id: 'edit.redo', label: 'Redo', category: 'Editing' },
|
||||
{ id: 'edit.cut', label: 'Cut', category: 'Editing' },
|
||||
{ id: 'edit.copy', label: 'Copy', category: 'Editing' },
|
||||
{ id: 'edit.paste', label: 'Paste', category: 'Editing' },
|
||||
{ id: 'edit.delete', label: 'Delete', category: 'Editing' },
|
||||
{ id: 'edit.selectAll', label: 'Select All', category: 'Editing' },
|
||||
{ id: 'edit.deselect', label: 'Deselect All', category: 'Editing' },
|
||||
{ id: 'edit.insertEdit', label: 'Insert Edit', category: 'Editing' },
|
||||
{ id: 'edit.overwriteEdit', label: 'Overwrite Edit', category: 'Editing' },
|
||||
{ id: 'edit.matchFrame', label: 'Match Frame', category: 'Editing', description: 'Load the clip under the playhead into the source monitor at the matching frame' },
|
||||
// Marking
|
||||
{ id: 'mark.setIn', label: 'Set In Point', category: 'Marking' },
|
||||
{ id: 'mark.setOut', label: 'Set Out Point', category: 'Marking' },
|
||||
{ id: 'mark.clearIn', label: 'Clear In Point', category: 'Marking' },
|
||||
{ id: 'mark.clearOut', label: 'Clear Out Point', category: 'Marking' },
|
||||
{ id: 'mark.clearInOut', label: 'Clear In / Out', category: 'Marking' },
|
||||
// Timeline
|
||||
{ id: 'timeline.zoomIn', label: 'Zoom In', category: 'Timeline' },
|
||||
{ id: 'timeline.zoomOut', label: 'Zoom Out', category: 'Timeline' },
|
||||
{ id: 'timeline.fitToView', label: 'Fit Timeline to View', category: 'Timeline' },
|
||||
{ id: 'timeline.toggleSnap', label: 'Toggle Snap', category: 'Timeline' },
|
||||
// Navigation
|
||||
{ id: 'nav.prevEdit', label: 'Go to Previous Edit Point', category: 'Transport', description: 'Jump playhead to previous cut on timeline' },
|
||||
{ id: 'nav.nextEdit', label: 'Go to Next Edit Point', category: 'Transport', description: 'Jump playhead to next cut on timeline' },
|
||||
{ id: 'view.fullscreen', label: 'Fullscreen Preview', category: 'Timeline' },
|
||||
]
|
||||
|
||||
// ── Keyboard Layout (mapping from ActionId to one or more key combos) ──
|
||||
export type KeyboardLayout = Partial<Record<ActionId, KeyCombo[]>>
|
||||
|
||||
// ── Named Preset ──
|
||||
export interface KeyboardPreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
layout: KeyboardLayout
|
||||
builtIn: boolean // true for factory presets, false for user-created
|
||||
}
|
||||
|
||||
// Helper to create key combos concisely
|
||||
function k(key: string, mods?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }): KeyCombo {
|
||||
return { key, ...mods }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ── PRESET: LTX Default ──
|
||||
// ═══════════════════════════════════════════
|
||||
export const LTX_DEFAULT_LAYOUT: KeyboardLayout = {
|
||||
// Tools
|
||||
'tool.select': [k('v')],
|
||||
'tool.blade': [k('b')],
|
||||
'tool.ripple': [k('r')],
|
||||
'tool.roll': [k('n')],
|
||||
'tool.slide': [k('u')],
|
||||
'tool.slip': [k('y')],
|
||||
'tool.trackForward': [k('a')],
|
||||
// Transport
|
||||
'transport.playPause': [k(' ')],
|
||||
'transport.shuttleReverse': [k('j')],
|
||||
'transport.shuttleStop': [k('k')],
|
||||
'transport.shuttleForward': [k('l')],
|
||||
'transport.stepBackward': [k('arrowleft')],
|
||||
'transport.stepForward': [k('arrowright')],
|
||||
'transport.jumpBackward': [k('arrowleft', { shift: true })],
|
||||
'transport.jumpForward': [k('arrowright', { shift: true })],
|
||||
'transport.goToStart': [k('home')],
|
||||
'transport.goToEnd': [k('end')],
|
||||
'transport.goToIn': [k('i', { shift: true })],
|
||||
'transport.goToOut': [k('o', { shift: true })],
|
||||
// Editing
|
||||
'edit.undo': [k('z', { ctrl: true })],
|
||||
'edit.redo': [k('z', { ctrl: true, shift: true }), k('y', { ctrl: true })],
|
||||
'edit.cut': [k('x', { ctrl: true })],
|
||||
'edit.copy': [k('c', { ctrl: true })],
|
||||
'edit.paste': [k('v', { ctrl: true })],
|
||||
'edit.delete': [k('delete'), k('backspace')],
|
||||
'edit.selectAll': [k('a', { ctrl: true })],
|
||||
'edit.deselect': [k('escape')],
|
||||
'edit.insertEdit': [k(',')],
|
||||
'edit.overwriteEdit': [k('.')],
|
||||
'edit.matchFrame': [k('f')],
|
||||
// Marking
|
||||
'mark.setIn': [k('i')],
|
||||
'mark.setOut': [k('o')],
|
||||
'mark.clearIn': [k('i', { alt: true })],
|
||||
'mark.clearOut': [k('o', { alt: true })],
|
||||
'mark.clearInOut': [k('x', { alt: true })],
|
||||
// Timeline
|
||||
'timeline.zoomIn': [k('='), k('+')],
|
||||
'timeline.zoomOut': [k('-')],
|
||||
'timeline.fitToView': [k('0', { ctrl: true })],
|
||||
'timeline.toggleSnap': [k('s')],
|
||||
// Navigation
|
||||
'nav.prevEdit': [k('arrowup')],
|
||||
'nav.nextEdit': [k('arrowdown')],
|
||||
'view.fullscreen': [k('`'), k('f11')],
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ── PRESET: Adobe Premiere Pro ──
|
||||
// ═══════════════════════════════════════════
|
||||
export const PREMIERE_LAYOUT: KeyboardLayout = {
|
||||
// Tools (Premiere defaults)
|
||||
'tool.select': [k('v')],
|
||||
'tool.blade': [k('c')], // Premiere uses C for razor
|
||||
'tool.ripple': [k('b')], // Premiere: B = ripple edit
|
||||
'tool.roll': [k('n')],
|
||||
'tool.slide': [k('u')],
|
||||
'tool.slip': [k('y')],
|
||||
'tool.trackForward': [k('a')],
|
||||
// Transport (same JKL)
|
||||
'transport.playPause': [k(' ')],
|
||||
'transport.shuttleReverse': [k('j')],
|
||||
'transport.shuttleStop': [k('k')],
|
||||
'transport.shuttleForward': [k('l')],
|
||||
'transport.stepBackward': [k('arrowleft')],
|
||||
'transport.stepForward': [k('arrowright')],
|
||||
'transport.jumpBackward': [k('arrowleft', { shift: true })],
|
||||
'transport.jumpForward': [k('arrowright', { shift: true })],
|
||||
'transport.goToStart': [k('home')],
|
||||
'transport.goToEnd': [k('end')],
|
||||
'transport.goToIn': [k('i', { shift: true })],
|
||||
'transport.goToOut': [k('o', { shift: true })],
|
||||
// Editing
|
||||
'edit.undo': [k('z', { ctrl: true })],
|
||||
'edit.redo': [k('z', { ctrl: true, shift: true })],
|
||||
'edit.cut': [k('x', { ctrl: true })],
|
||||
'edit.copy': [k('c', { ctrl: true })],
|
||||
'edit.paste': [k('v', { ctrl: true })],
|
||||
'edit.delete': [k('delete'), k('backspace')],
|
||||
'edit.selectAll': [k('a', { ctrl: true })],
|
||||
'edit.deselect': [k('escape')],
|
||||
'edit.insertEdit': [k(',')], // Premiere: , = insert
|
||||
'edit.overwriteEdit': [k('.')], // Premiere: . = overwrite
|
||||
'edit.matchFrame': [k('f')], // Premiere: F = match frame
|
||||
// Marking (same as Premiere)
|
||||
'mark.setIn': [k('i')],
|
||||
'mark.setOut': [k('o')],
|
||||
'mark.clearIn': [k('i', { alt: true })], // Premiere: Alt+I / Option+I
|
||||
'mark.clearOut': [k('o', { alt: true })], // Premiere: Alt+O / Option+O
|
||||
'mark.clearInOut': [k('x', { alt: true })], // Premiere: Alt+X or Option+X
|
||||
// Timeline
|
||||
'timeline.zoomIn': [k('=')],
|
||||
'timeline.zoomOut': [k('-')],
|
||||
'timeline.fitToView': [k('\\')], // Premiere: backslash fits timeline
|
||||
'timeline.toggleSnap': [k('s')], // Premiere: S = snap
|
||||
// Navigation (Premiere: Up/Down = previous/next edit)
|
||||
'nav.prevEdit': [k('arrowup')],
|
||||
'nav.nextEdit': [k('arrowdown')],
|
||||
'view.fullscreen': [k('`')], // Premiere: ` = fullscreen
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ── PRESET: DaVinci Resolve ──
|
||||
// ═══════════════════════════════════════════
|
||||
export const DAVINCI_LAYOUT: KeyboardLayout = {
|
||||
// Tools
|
||||
'tool.select': [k('a')], // DaVinci: A = selection
|
||||
'tool.blade': [k('b')], // DaVinci: B = blade
|
||||
'tool.ripple': [k('t')], // DaVinci: T = trim (contextual ripple/roll)
|
||||
'tool.roll': [k('g')], // DaVinci has no separate roll key; G assigned to avoid conflict
|
||||
'tool.slide': [k('u')],
|
||||
'tool.slip': [k('s')], // DaVinci: S = slip (not snap)
|
||||
'tool.trackForward': [k('y')],
|
||||
// Transport
|
||||
'transport.playPause': [k(' ')],
|
||||
'transport.shuttleReverse': [k('j')],
|
||||
'transport.shuttleStop': [k('k')],
|
||||
'transport.shuttleForward': [k('l')],
|
||||
'transport.stepBackward': [k('arrowleft')],
|
||||
'transport.stepForward': [k('arrowright')],
|
||||
'transport.jumpBackward': [k('arrowleft', { shift: true })],
|
||||
'transport.jumpForward': [k('arrowright', { shift: true })],
|
||||
'transport.goToStart': [k('home')],
|
||||
'transport.goToEnd': [k('end')],
|
||||
'transport.goToIn': [k('i', { shift: true })],
|
||||
'transport.goToOut': [k('o', { shift: true })],
|
||||
// Editing
|
||||
'edit.undo': [k('z', { ctrl: true })],
|
||||
'edit.redo': [k('z', { ctrl: true, shift: true })],
|
||||
'edit.cut': [k('x', { ctrl: true })],
|
||||
'edit.copy': [k('c', { ctrl: true })],
|
||||
'edit.paste': [k('v', { ctrl: true })],
|
||||
'edit.delete': [k('delete'), k('backspace')],
|
||||
'edit.selectAll': [k('a', { ctrl: true })],
|
||||
'edit.deselect': [k('escape')],
|
||||
'edit.insertEdit': [k('f9')], // DaVinci: F9 = insert
|
||||
'edit.overwriteEdit': [k('f10')], // DaVinci: F10 = overwrite
|
||||
'edit.matchFrame': [k('f')], // DaVinci: F = match frame
|
||||
// Marking
|
||||
'mark.setIn': [k('i')],
|
||||
'mark.setOut': [k('o')],
|
||||
'mark.clearIn': [k('i', { alt: true })],
|
||||
'mark.clearOut': [k('o', { alt: true })],
|
||||
'mark.clearInOut': [k('x', { alt: true })],
|
||||
// Timeline
|
||||
'timeline.zoomIn': [k('=', { ctrl: true })],
|
||||
'timeline.zoomOut': [k('-', { ctrl: true })],
|
||||
'timeline.fitToView': [k('z', { shift: true })], // DaVinci: Shift+Z = fit
|
||||
'timeline.toggleSnap': [k('n')], // DaVinci: N = snap
|
||||
// Navigation (DaVinci: Up/Down = previous/next edit)
|
||||
'nav.prevEdit': [k('arrowup')],
|
||||
'nav.nextEdit': [k('arrowdown')],
|
||||
'view.fullscreen': [k('p', { ctrl: true, shift: true })], // DaVinci: Ctrl+Shift+P
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ── PRESET: Avid Media Composer ──
|
||||
// ═══════════════════════════════════════════
|
||||
export const AVID_LAYOUT: KeyboardLayout = {
|
||||
// Tools
|
||||
'tool.select': [k('v')],
|
||||
'tool.blade': [k('/')], // Avid uses different paradigm but closest
|
||||
'tool.ripple': [k('r')],
|
||||
'tool.roll': [k('n')],
|
||||
'tool.slide': [k('u')],
|
||||
'tool.slip': [k('y')],
|
||||
'tool.trackForward': [k('a')],
|
||||
// Transport — Avid uses different keys
|
||||
'transport.playPause': [k(' '), k('5')], // Avid: 5 or Space
|
||||
'transport.shuttleReverse': [k('j')],
|
||||
'transport.shuttleStop': [k('k')],
|
||||
'transport.shuttleForward': [k('l')],
|
||||
'transport.stepBackward': [k('3')], // Avid: 3 = step back
|
||||
'transport.stepForward': [k('4')], // Avid: 4 = step forward
|
||||
'transport.jumpBackward': [k('1')], // Avid: 1 = fast reverse
|
||||
'transport.jumpForward': [k('2')], // Avid: 2 = fast forward
|
||||
'transport.goToStart': [k('home')],
|
||||
'transport.goToEnd': [k('end')],
|
||||
'transport.goToIn': [k('i', { shift: true })],
|
||||
'transport.goToOut': [k('o', { shift: true })],
|
||||
// Editing
|
||||
'edit.undo': [k('z', { ctrl: true })],
|
||||
'edit.redo': [k('z', { ctrl: true, shift: true })],
|
||||
'edit.cut': [k('x', { ctrl: true })],
|
||||
'edit.copy': [k('c', { ctrl: true })],
|
||||
'edit.paste': [k('v', { ctrl: true })],
|
||||
'edit.delete': [k('delete'), k('backspace')],
|
||||
'edit.selectAll': [k('a', { ctrl: true })],
|
||||
'edit.deselect': [k('escape')],
|
||||
'edit.insertEdit': [k('v', { shift: true })], // Avid: Shift+V to avoid conflict with tool.select
|
||||
'edit.overwriteEdit': [k('b')], // Avid: B = overwrite
|
||||
'edit.matchFrame': [k('f')], // Avid: match frame
|
||||
// Marking — Avid classic: I/O or E/R
|
||||
'mark.setIn': [k('i'), k('e')], // Avid: E = mark in
|
||||
'mark.setOut': [k('o')], // Avid: O = mark out (R removed to avoid conflict with tool.ripple)
|
||||
'mark.clearIn': [k('i', { alt: true })],
|
||||
'mark.clearOut': [k('o', { alt: true })],
|
||||
'mark.clearInOut': [k('g')], // Avid: G = clear both marks
|
||||
// Timeline
|
||||
'timeline.zoomIn': [k('=', { ctrl: true })],
|
||||
'timeline.zoomOut': [k('-', { ctrl: true })],
|
||||
'timeline.fitToView': [k('0', { ctrl: true })],
|
||||
'timeline.toggleSnap': [k('s')],
|
||||
// Navigation (Avid: similar to A/S or arrow keys)
|
||||
'nav.prevEdit': [k('arrowup')],
|
||||
'nav.nextEdit': [k('arrowdown')],
|
||||
'view.fullscreen': [k('`'), k('f11')],
|
||||
}
|
||||
|
||||
// ── All Built-in Presets ──
|
||||
export const BUILT_IN_PRESETS: KeyboardPreset[] = [
|
||||
{
|
||||
id: 'ltx-default',
|
||||
name: 'LTX Default',
|
||||
description: 'Default keyboard layout for LTX Desktop',
|
||||
layout: LTX_DEFAULT_LAYOUT,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'premiere',
|
||||
name: 'Adobe Premiere Pro',
|
||||
description: 'Keyboard layout matching Premiere Pro defaults',
|
||||
layout: PREMIERE_LAYOUT,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'davinci',
|
||||
name: 'DaVinci Resolve',
|
||||
description: 'Keyboard layout matching DaVinci Resolve defaults',
|
||||
layout: DAVINCI_LAYOUT,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'avid',
|
||||
name: 'Avid Media Composer',
|
||||
description: 'Keyboard layout matching Avid Media Composer defaults',
|
||||
layout: AVID_LAYOUT,
|
||||
builtIn: true,
|
||||
},
|
||||
]
|
||||
|
||||
// ── Utilities ──
|
||||
|
||||
/** Format a KeyCombo into a human-readable string like "Ctrl+Shift+B" */
|
||||
export function formatKeyCombo(combo: KeyCombo): string {
|
||||
const parts: string[] = []
|
||||
if (combo.ctrl || combo.meta) parts.push('Ctrl')
|
||||
if (combo.shift) parts.push('Shift')
|
||||
if (combo.alt) parts.push('Alt')
|
||||
|
||||
// Pretty-print special keys
|
||||
const keyMap: Record<string, string> = {
|
||||
' ': 'Space',
|
||||
'arrowleft': '\u2190',
|
||||
'arrowright': '\u2192',
|
||||
'arrowup': '\u2191',
|
||||
'arrowdown': '\u2193',
|
||||
'delete': 'Del',
|
||||
'backspace': 'Bksp',
|
||||
'escape': 'Esc',
|
||||
'enter': 'Enter',
|
||||
',': ',',
|
||||
'.': '.',
|
||||
'/': '/',
|
||||
'\\': '\\',
|
||||
'=': '=',
|
||||
'+': '+',
|
||||
'-': '-',
|
||||
'home': 'Home',
|
||||
'end': 'End',
|
||||
'f9': 'F9',
|
||||
'f10': 'F10',
|
||||
}
|
||||
parts.push(keyMap[combo.key] || combo.key.toUpperCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/** Map a physical KeyboardEvent.code to a logical key string matching KeyCombo.key */
|
||||
function codeToKey(code: string): string {
|
||||
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
|
||||
if (code.startsWith('Digit')) return code.slice(5)
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
/** Check if a keyboard event matches a key combo */
|
||||
export function eventMatchesCombo(e: KeyboardEvent, combo: KeyCombo): boolean {
|
||||
// On Mac, Option (alt) remaps e.key to special characters (e.g. Option+I → 'Dead', Option+O → 'ø').
|
||||
// Use the physical key code instead when alt is involved so shortcuts still resolve correctly.
|
||||
const key = (combo.alt && e.altKey) ? codeToKey(e.code) : e.key.toLowerCase()
|
||||
if (key !== combo.key) return false
|
||||
if (!!combo.ctrl !== (e.ctrlKey || e.metaKey)) return false
|
||||
if (!!combo.shift !== e.shiftKey) return false
|
||||
if (!!combo.alt !== e.altKey) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Given a layout and an event, return the matching ActionId (or null) */
|
||||
export function resolveAction(layout: KeyboardLayout, e: KeyboardEvent): ActionId | null {
|
||||
for (const [actionId, combos] of Object.entries(layout)) {
|
||||
if (!combos) continue
|
||||
for (const combo of combos) {
|
||||
if (eventMatchesCombo(e, combo)) {
|
||||
return actionId as ActionId
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Find conflicts: actions that share the same key combo */
|
||||
export function findConflicts(layout: KeyboardLayout): Map<string, ActionId[]> {
|
||||
const comboMap = new Map<string, ActionId[]>()
|
||||
for (const [actionId, combos] of Object.entries(layout)) {
|
||||
if (!combos) continue
|
||||
for (const combo of combos) {
|
||||
const key = formatKeyCombo(combo)
|
||||
const existing = comboMap.get(key) || []
|
||||
existing.push(actionId as ActionId)
|
||||
comboMap.set(key, existing)
|
||||
}
|
||||
}
|
||||
// Only return entries with conflicts (more than 1 action)
|
||||
const conflicts = new Map<string, ActionId[]>()
|
||||
for (const [key, actions] of comboMap) {
|
||||
if (actions.length > 1) conflicts.set(key, actions)
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
/** Deep clone a layout for mutation */
|
||||
export function cloneLayout(layout: KeyboardLayout): KeyboardLayout {
|
||||
const result: KeyboardLayout = {}
|
||||
for (const [key, combos] of Object.entries(layout)) {
|
||||
if (combos) {
|
||||
result[key as ActionId] = combos.map(c => ({ ...c }))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { applyTimecode, nudgeKeyframe } from './keyframe-controls.ts'
|
||||
|
||||
const keyframes = [
|
||||
{ id: 'opening', frameIndex: 10 },
|
||||
{ id: 'middle', frameIndex: 11 },
|
||||
{ id: 'closing', frameIndex: 20 },
|
||||
]
|
||||
|
||||
describe('nudgeKeyframe', () => {
|
||||
it('moves a keyframe one frame in the requested direction', () => {
|
||||
assert.equal(nudgeKeyframe(keyframes, 'closing', -1, 30), 19)
|
||||
})
|
||||
|
||||
it('never lands on another keyframe', () => {
|
||||
const frameIndex = nudgeKeyframe(keyframes, 'opening', 1, 30)
|
||||
|
||||
assert.equal(frameIndex, 12)
|
||||
assert.equal(
|
||||
keyframes.some((keyframe) => (
|
||||
keyframe.id !== 'opening' && keyframe.frameIndex === frameIndex
|
||||
)),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an unknown keyframe', () => {
|
||||
assert.equal(nudgeKeyframe(keyframes, 'missing', 1, 30), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTimecode', () => {
|
||||
it('converts a valid timecode to a frame index', () => {
|
||||
assert.equal(applyTimecode(keyframes, 'opening', '00:00.13', 25, 30), 13)
|
||||
})
|
||||
|
||||
it('moves a colliding timecode to the nearest free frame', () => {
|
||||
const frameIndex = applyTimecode(keyframes, 'opening', '00:00.11', 25, 30)
|
||||
|
||||
assert.equal(frameIndex, 12)
|
||||
assert.equal(
|
||||
keyframes.some((keyframe) => (
|
||||
keyframe.id !== 'opening' && keyframe.frameIndex === frameIndex
|
||||
)),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for invalid timecode', () => {
|
||||
assert.equal(applyTimecode(keyframes, 'opening', 'invalid', 25, 30), null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
findNearestFreeFrameIndex,
|
||||
parseKeyframeTimecode,
|
||||
} from './keyframe-timeline.ts'
|
||||
|
||||
type PositionedKeyframe = {
|
||||
id: string
|
||||
frameIndex: number
|
||||
}
|
||||
|
||||
function placementContext(
|
||||
keyframes: readonly PositionedKeyframe[],
|
||||
id: string,
|
||||
): { current: PositionedKeyframe; others: PositionedKeyframe[] } | null {
|
||||
const current = keyframes.find((keyframe) => keyframe.id === id)
|
||||
if (!current) return null
|
||||
|
||||
return {
|
||||
current,
|
||||
others: keyframes.filter((keyframe) => keyframe.id !== id),
|
||||
}
|
||||
}
|
||||
|
||||
export function nudgeKeyframe(
|
||||
keyframes: readonly PositionedKeyframe[],
|
||||
id: string,
|
||||
delta: -1 | 1,
|
||||
lastFrame: number,
|
||||
): number | null {
|
||||
const context = placementContext(keyframes, id)
|
||||
if (!context) return null
|
||||
|
||||
return findNearestFreeFrameIndex(
|
||||
context.others,
|
||||
context.current.frameIndex + delta,
|
||||
lastFrame,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyTimecode(
|
||||
keyframes: readonly PositionedKeyframe[],
|
||||
id: string,
|
||||
value: string,
|
||||
fps: number,
|
||||
lastFrame: number,
|
||||
): number | null {
|
||||
const context = placementContext(keyframes, id)
|
||||
const parsedFrame = parseKeyframeTimecode(value, fps)
|
||||
if (!context || parsedFrame === null) return null
|
||||
|
||||
return findNearestFreeFrameIndex(context.others, parsedFrame, lastFrame)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { imagePathsFromDataTransfer, imagePathsFromFiles } from './keyframe-drop.ts'
|
||||
|
||||
describe('imagePathsFromFiles', () => {
|
||||
it('keeps image files in selection order and skips non-images', () => {
|
||||
const files = [
|
||||
new File(['a'], 'a.png', { type: 'image/png' }),
|
||||
new File(['b'], 'notes.txt', { type: 'text/plain' }),
|
||||
new File(['c'], 'c.jpg', { type: 'image/jpeg' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
imagePathsFromFiles(files, (file) => `/picked/${file.name}`),
|
||||
['/picked/a.png', '/picked/c.jpg'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imagePathsFromDataTransfer', () => {
|
||||
it('reads a gallery image asset', () => {
|
||||
const dataTransfer = {
|
||||
getData: (type: string) => (
|
||||
type === 'asset'
|
||||
? JSON.stringify({ type: 'image', path: '/gallery/still.png' })
|
||||
: ''
|
||||
),
|
||||
files: [] as unknown as FileList,
|
||||
} as DataTransfer
|
||||
|
||||
assert.deepEqual(imagePathsFromDataTransfer(dataTransfer), ['/gallery/still.png'])
|
||||
})
|
||||
|
||||
it('ignores non-image gallery assets', () => {
|
||||
const dataTransfer = {
|
||||
getData: (type: string) => (
|
||||
type === 'asset'
|
||||
? JSON.stringify({ type: 'video', path: '/gallery/clip.mp4' })
|
||||
: ''
|
||||
),
|
||||
files: [] as unknown as FileList,
|
||||
} as DataTransfer
|
||||
|
||||
assert.deepEqual(imagePathsFromDataTransfer(dataTransfer), [])
|
||||
})
|
||||
|
||||
it('collects dropped OS image files when no gallery asset is present', () => {
|
||||
const files = [
|
||||
new File(['a'], 'one.png', { type: 'image/png' }),
|
||||
new File(['b'], 'two.png', { type: 'image/png' }),
|
||||
]
|
||||
const dataTransfer = {
|
||||
getData: () => '',
|
||||
files,
|
||||
} as unknown as DataTransfer
|
||||
|
||||
assert.deepEqual(
|
||||
imagePathsFromDataTransfer(dataTransfer, (file) => `/os/${file.name}`),
|
||||
['/os/one.png', '/os/two.png'],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
type ImagePathResolver = (file: File) => string | null
|
||||
|
||||
function defaultPathForFile(file: File): string | null {
|
||||
return window.electronAPI?.getPathForFile(file) ?? null
|
||||
}
|
||||
|
||||
export function imagePathsFromFiles(
|
||||
files: ArrayLike<File>,
|
||||
getPathForFile: ImagePathResolver = defaultPathForFile,
|
||||
): string[] {
|
||||
const paths: string[] = []
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]
|
||||
if (!file.type.startsWith('image/')) continue
|
||||
const path = getPathForFile(file)
|
||||
if (path) paths.push(path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export function imagePathsFromDataTransfer(
|
||||
dataTransfer: DataTransfer,
|
||||
getPathForFile: ImagePathResolver = defaultPathForFile,
|
||||
): string[] {
|
||||
const assetData = dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
return asset.type === 'image' && asset.path ? [asset.path] : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return imagePathsFromFiles(dataTransfer.files, getPathForFile)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
DEFAULT_KEYFRAME_STRENGTH,
|
||||
formatKeyframeStrength,
|
||||
nudgeKeyframeStrength,
|
||||
strengthFromPointer,
|
||||
} from './keyframe-strength.ts'
|
||||
|
||||
describe('strengthFromPointer', () => {
|
||||
const rail = { top: 10, height: 100 }
|
||||
|
||||
it('maps the top of the rail to full strength', () => {
|
||||
assert.equal(strengthFromPointer(10, rail), 1)
|
||||
})
|
||||
|
||||
it('maps the bottom of the rail to zero strength', () => {
|
||||
assert.equal(strengthFromPointer(110, rail), 0)
|
||||
})
|
||||
|
||||
it('maps the midpoint to 0.5', () => {
|
||||
assert.equal(strengthFromPointer(60, rail), 0.5)
|
||||
})
|
||||
|
||||
it('clamps above the rail to 1 and below it to 0', () => {
|
||||
assert.equal(strengthFromPointer(0, rail), 1)
|
||||
assert.equal(strengthFromPointer(200, rail), 0)
|
||||
})
|
||||
|
||||
it('returns the new-still default when the rail has no height', () => {
|
||||
assert.equal(strengthFromPointer(10, { top: 10, height: 0 }), DEFAULT_KEYFRAME_STRENGTH)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nudgeKeyframeStrength', () => {
|
||||
it('steps by five percent', () => {
|
||||
assert.equal(nudgeKeyframeStrength(0.7, 1), 0.75)
|
||||
assert.equal(nudgeKeyframeStrength(0.7, -1), 0.65)
|
||||
})
|
||||
|
||||
it('clamps at 0 and 1', () => {
|
||||
assert.equal(nudgeKeyframeStrength(0.02, -1), 0)
|
||||
assert.equal(nudgeKeyframeStrength(0.98, 1), 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatKeyframeStrength', () => {
|
||||
it('renders owned strength as a percent', () => {
|
||||
assert.equal(formatKeyframeStrength(0.7), '70%')
|
||||
assert.equal(formatKeyframeStrength(0), '0%')
|
||||
assert.equal(formatKeyframeStrength(1), '100%')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Newly placed GenSpace stills. Loose enough for Distilled all-guide interpolation. */
|
||||
export const DEFAULT_KEYFRAME_STRENGTH = 0.7
|
||||
|
||||
/** Omitted/invalid persist and HTTP. Matches the backend KeyframeInput Field default. */
|
||||
export const MISSING_KEYFRAME_STRENGTH = 1
|
||||
|
||||
/** ArrowUp / ArrowDown and the strength rail step in 5% increments. */
|
||||
export const KEYFRAME_STRENGTH_STEP = 0.05
|
||||
|
||||
/** Persist, restore, and HTTP all go through this so a bad float cannot 422 or fail project parse. */
|
||||
export function clampKeyframeStrength(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return MISSING_KEYFRAME_STRENGTH
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function clampUnit(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function roundStrength(value: number): number {
|
||||
return Math.round(clampUnit(value) * 100) / 100
|
||||
}
|
||||
|
||||
/** Top of the rail is 1 (full lock), bottom is 0 (no lock). */
|
||||
export function strengthFromPointer(
|
||||
clientY: number,
|
||||
railRect: { top: number; height: number },
|
||||
): number {
|
||||
if (!(railRect.height > 0) || !Number.isFinite(clientY)) return DEFAULT_KEYFRAME_STRENGTH
|
||||
return roundStrength(1 - (clientY - railRect.top) / railRect.height)
|
||||
}
|
||||
|
||||
export function nudgeKeyframeStrength(strength: number, direction: -1 | 1): number {
|
||||
const current = typeof strength === 'number' && Number.isFinite(strength)
|
||||
? clampUnit(strength)
|
||||
: DEFAULT_KEYFRAME_STRENGTH
|
||||
return roundStrength(current + direction * KEYFRAME_STRENGTH_STEP)
|
||||
}
|
||||
|
||||
export function formatKeyframeStrength(strength: number): string {
|
||||
const current = typeof strength === 'number' && Number.isFinite(strength)
|
||||
? clampUnit(strength)
|
||||
: DEFAULT_KEYFRAME_STRENGTH
|
||||
return `${Math.round(current * 100)}%`
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
findNearestFreeFrameIndex,
|
||||
formatKeyframeTimecode,
|
||||
frameFromPointer,
|
||||
keyframeAtOrBefore,
|
||||
lastFrameFromDuration,
|
||||
parseKeyframeTimecode,
|
||||
pickFreeFrameIndex,
|
||||
previewKeyframeForPlayhead,
|
||||
retimeKeyframes,
|
||||
retimeKeyframesForSettings,
|
||||
sameDraggedFrame,
|
||||
withDraggedFrame,
|
||||
} from './keyframe-timeline.ts'
|
||||
|
||||
type TestKeyframe = { frameIndex: number; id: string; path?: string }
|
||||
|
||||
function keyframe(frameIndex: number, id = `file-${frameIndex}`): TestKeyframe {
|
||||
return { frameIndex, id }
|
||||
}
|
||||
|
||||
describe('lastFrameFromDuration', () => {
|
||||
it('matches backend compute_num_frames - 1 so stills cannot sit past the clip', () => {
|
||||
assert.equal(lastFrameFromDuration(5, 24), 120)
|
||||
assert.equal(lastFrameFromDuration(5, 25), 120)
|
||||
assert.equal(lastFrameFromDuration(5, 50), 248)
|
||||
assert.equal(lastFrameFromDuration(10, 24), 240)
|
||||
assert.equal(lastFrameFromDuration(10, 25), 248)
|
||||
assert.equal(lastFrameFromDuration(10, 50), 496)
|
||||
assert.equal(lastFrameFromDuration(4, 50), 200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frameFromPointer', () => {
|
||||
const trackRect = { left: 100, width: 200 }
|
||||
const lastFrame = 100
|
||||
|
||||
it('maps the left edge to frame 0', () => {
|
||||
assert.equal(frameFromPointer(100, trackRect, lastFrame), 0)
|
||||
})
|
||||
|
||||
it('maps the right edge to lastFrame', () => {
|
||||
assert.equal(frameFromPointer(300, trackRect, lastFrame), lastFrame)
|
||||
})
|
||||
|
||||
it('maps the midpoint to the middle frame', () => {
|
||||
assert.equal(frameFromPointer(200, trackRect, lastFrame), 50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatKeyframeTimecode', () => {
|
||||
const cases = [
|
||||
{ fps: 24, frameIndex: 0, expected: '00:00.00' },
|
||||
{ fps: 24, frameIndex: 23, expected: '00:00.23' },
|
||||
{ fps: 24, frameIndex: 24, expected: '00:01.00' },
|
||||
{ fps: 25, frameIndex: 24, expected: '00:00.24' },
|
||||
{ fps: 25, frameIndex: 25, expected: '00:01.00' },
|
||||
{ fps: 48, frameIndex: 49, expected: '00:01.01' },
|
||||
{ fps: 50, frameIndex: 3005, expected: '01:00.05' },
|
||||
] as const
|
||||
|
||||
for (const { fps, frameIndex, expected } of cases) {
|
||||
it(`renders frame ${frameIndex} at ${fps} fps as ${expected}`, () => {
|
||||
assert.equal(formatKeyframeTimecode(frameIndex, fps), expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('parseKeyframeTimecode', () => {
|
||||
it('accepts a frames field only below the frame rate', () => {
|
||||
assert.equal(parseKeyframeTimecode('00:01.24', 25), 49)
|
||||
assert.equal(parseKeyframeTimecode('00:01.25', 25), null)
|
||||
assert.equal(parseKeyframeTimecode('00:01.25', 48), 73)
|
||||
})
|
||||
|
||||
it('defaults a missing frames field to the first frame of that second', () => {
|
||||
assert.equal(parseKeyframeTimecode('01:02', 24), 62 * 24)
|
||||
})
|
||||
|
||||
for (const value of ['', '1', '00:60.00', '00:01.', 'a:bb.cc', '00:01:02']) {
|
||||
it(`rejects ${JSON.stringify(value)}`, () => {
|
||||
assert.equal(parseKeyframeTimecode(value, 25), null)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fps of [24, 25, 48, 50]) {
|
||||
it(`round-trips every frame of two seconds at ${fps} fps`, () => {
|
||||
for (let frameIndex = 0; frameIndex < fps * 2; frameIndex++) {
|
||||
assert.equal(
|
||||
parseKeyframeTimecode(formatKeyframeTimecode(frameIndex, fps), fps),
|
||||
frameIndex,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('pickFreeFrameIndex', () => {
|
||||
it('uses the preferred frame when it is free', () => {
|
||||
assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(100)], 200, 40), 40)
|
||||
})
|
||||
|
||||
it('clamps a preferred frame past the end onto the last frame', () => {
|
||||
assert.equal(pickFreeFrameIndex([], 200, 500), 200)
|
||||
})
|
||||
|
||||
it('falls back to the middle of the widest free stretch', () => {
|
||||
assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(9)], 10, 0), 4)
|
||||
})
|
||||
|
||||
it('spreads a burst of adds instead of clumping them at the playhead', () => {
|
||||
const taken: TestKeyframe[] = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const frameIndex = pickFreeFrameIndex(taken, 100, 0)
|
||||
assert.notEqual(frameIndex, null)
|
||||
taken.push(keyframe(frameIndex!))
|
||||
}
|
||||
assert.equal(new Set(taken.map(({ frameIndex }) => frameIndex)).size, 4)
|
||||
assert.deepEqual(taken.map(({ frameIndex }) => frameIndex), [0, 50, 75, 25])
|
||||
})
|
||||
|
||||
it('returns null when every frame is taken', () => {
|
||||
assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(1), keyframe(2)], 2, 1), null)
|
||||
})
|
||||
|
||||
it('ignores taken indices outside the legal range when checking fullness', () => {
|
||||
assert.equal(pickFreeFrameIndex([keyframe(-1), keyframe(100)], 1, 0), 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNearestFreeFrameIndex', () => {
|
||||
it('never returns an occupied index', () => {
|
||||
const taken = [keyframe(10), keyframe(11), keyframe(12)]
|
||||
const occupied = new Set(taken.map(({ frameIndex }) => frameIndex))
|
||||
for (let target = 0; target <= 20; target++) {
|
||||
const result = findNearestFreeFrameIndex(taken, target, 20)
|
||||
if (result !== null) {
|
||||
assert.equal(occupied.has(result), false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('lands on an adjacent free frame when dropped on an occupied one', () => {
|
||||
assert.equal(findNearestFreeFrameIndex([keyframe(10)], 10, 100), 11)
|
||||
assert.equal(findNearestFreeFrameIndex([keyframe(10), keyframe(11)], 10, 100), 9)
|
||||
})
|
||||
|
||||
it('does not jump to a far gap when a nearer frame is free', () => {
|
||||
assert.equal(findNearestFreeFrameIndex([keyframe(50)], 50, 100), 51)
|
||||
assert.notEqual(findNearestFreeFrameIndex([keyframe(50)], 50, 100), 0)
|
||||
})
|
||||
|
||||
it('ignores taken indices outside the legal range', () => {
|
||||
assert.equal(findNearestFreeFrameIndex([keyframe(-1), keyframe(100)], 0, 1), 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retimeKeyframes', () => {
|
||||
it('keeps keyframes at the same relative place when the frame rate changes', () => {
|
||||
const retimed = retimeKeyframes([keyframe(0), keyframe(50)], 249, 499)
|
||||
assert.deepEqual(
|
||||
retimed.map((k) => k.frameIndex),
|
||||
[0, 100],
|
||||
)
|
||||
})
|
||||
|
||||
it('scales keyframes across a shorter clip instead of packing the tail', () => {
|
||||
const retimed = retimeKeyframes(
|
||||
[keyframe(0), keyframe(100), keyframe(200)],
|
||||
200,
|
||||
100,
|
||||
)
|
||||
assert.deepEqual(
|
||||
retimed.map((k) => k.frameIndex),
|
||||
[0, 50, 100],
|
||||
)
|
||||
})
|
||||
|
||||
it('scales keyframes across a longer clip instead of leaving them bunched', () => {
|
||||
const retimed = retimeKeyframes(
|
||||
[keyframe(0), keyframe(50), keyframe(100)],
|
||||
100,
|
||||
200,
|
||||
)
|
||||
assert.deepEqual(
|
||||
retimed.map((k) => k.frameIndex),
|
||||
[0, 100, 200],
|
||||
)
|
||||
})
|
||||
|
||||
it('never collapses two keyframes onto one frame', () => {
|
||||
const retimed = retimeKeyframes([keyframe(10), keyframe(11)], 100, 100)
|
||||
assert.equal(new Set(retimed.map((k) => k.frameIndex)).size, 2)
|
||||
assert.deepEqual(
|
||||
retimed.map((k) => k.id),
|
||||
['file-10', 'file-11'],
|
||||
)
|
||||
})
|
||||
|
||||
it('drops excess keyframes when there are more keys than slots', () => {
|
||||
const retimed = retimeKeyframes(
|
||||
[keyframe(0, 'a'), keyframe(50, 'b'), keyframe(100, 'c')],
|
||||
100,
|
||||
1,
|
||||
)
|
||||
assert.equal(retimed.length, 2)
|
||||
assert.equal(new Set(retimed.map((k) => k.frameIndex)).size, 2)
|
||||
assert.deepEqual(
|
||||
retimed.map((k) => k.frameIndex),
|
||||
[0, 1],
|
||||
)
|
||||
assert.deepEqual(retimed.map((k) => k.id), ['a', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('retimeKeyframesForSettings', () => {
|
||||
it('retimes retained keyframes for settings changes made outside multi-keyframe mode', () => {
|
||||
const keyframes = [
|
||||
{ id: 'opening', path: '/opening.png', frameIndex: 25 },
|
||||
{ id: 'closing', path: '/closing.png', frameIndex: 200 },
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
retimeKeyframesForSettings(
|
||||
keyframes,
|
||||
{ duration: 10, fps: 25 },
|
||||
{ duration: 10, fps: 50 },
|
||||
),
|
||||
[
|
||||
{ id: 'opening', path: '/opening.png', frameIndex: 50 },
|
||||
{ id: 'closing', path: '/closing.png', frameIndex: 400 },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('scales a 10s clip down to 5s at 24 fps without packing the tail', () => {
|
||||
const keyframes = [
|
||||
{ id: 'opening', path: '/opening.png', frameIndex: 0 },
|
||||
{ id: 'middle', path: '/middle.png', frameIndex: 120 },
|
||||
{ id: 'closing', path: '/closing.png', frameIndex: 240 },
|
||||
]
|
||||
|
||||
const retimed = retimeKeyframesForSettings(
|
||||
keyframes,
|
||||
{ duration: 10, fps: 24 },
|
||||
{ duration: 5, fps: 24 },
|
||||
)
|
||||
|
||||
assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [0, 60, 120])
|
||||
assert.equal(new Set(retimed.map(({ frameIndex }) => frameIndex)).size, retimed.length)
|
||||
assert.deepEqual(
|
||||
retimed.map(({ id, path }) => ({ id, path })),
|
||||
keyframes.map(({ id, path }) => ({ id, path })),
|
||||
)
|
||||
})
|
||||
|
||||
it('scales a 5s clip up to 10s at 24 fps instead of leaving keys bunched', () => {
|
||||
const retimed = retimeKeyframesForSettings(
|
||||
[
|
||||
{ id: 'opening', path: '/opening.png', frameIndex: 0 },
|
||||
{ id: 'middle', path: '/middle.png', frameIndex: 60 },
|
||||
{ id: 'closing', path: '/closing.png', frameIndex: 120 },
|
||||
],
|
||||
{ duration: 5, fps: 24 },
|
||||
{ duration: 10, fps: 24 },
|
||||
)
|
||||
|
||||
assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [0, 120, 240])
|
||||
})
|
||||
|
||||
it('must not treat 10s-authored stills as a scale-up from the 5s remount default', () => {
|
||||
const authoredOn10s = [
|
||||
{ id: 'opening', path: '/opening.png', frameIndex: 0 },
|
||||
{ id: 'middle', path: '/middle.png', frameIndex: 120 },
|
||||
{ id: 'closing', path: '/closing.png', frameIndex: 240 },
|
||||
]
|
||||
const remountDefault = { duration: 5, fps: 24 }
|
||||
const recoveredClip = { duration: 10, fps: 24 }
|
||||
|
||||
// Reload mid-generation restores stills that already live on the recovered
|
||||
// clip. If the duration/fps effect still thinks previous=5s, it scales
|
||||
// frame 240 as if it were the 5s tail and bunches keys at the new end.
|
||||
assert.deepEqual(
|
||||
retimeKeyframesForSettings(authoredOn10s, remountDefault, recoveredClip).map(
|
||||
({ frameIndex }) => frameIndex,
|
||||
),
|
||||
[0, 239, 240],
|
||||
)
|
||||
assert.deepEqual(
|
||||
retimeKeyframesForSettings(authoredOn10s, recoveredClip, recoveredClip).map(
|
||||
({ frameIndex }) => frameIndex,
|
||||
),
|
||||
[0, 120, 240],
|
||||
)
|
||||
})
|
||||
|
||||
it('retimes and clamps when Auto changes to a numeric duration', () => {
|
||||
const retimed = retimeKeyframesForSettings(
|
||||
[keyframe(75), keyframe(100)],
|
||||
{ duration: null, fps: 25 },
|
||||
{ duration: 4, fps: 50 },
|
||||
)
|
||||
|
||||
assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [156, 200])
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyframeAtOrBefore', () => {
|
||||
const opening = keyframe(0, 'opening')
|
||||
const middle = keyframe(40, 'middle')
|
||||
const closing = keyframe(80, 'closing')
|
||||
|
||||
it('returns the keyframe on the playhead', () => {
|
||||
assert.equal(keyframeAtOrBefore([opening, middle, closing], 40), middle)
|
||||
})
|
||||
|
||||
it('holds the previous keyframe in a gap', () => {
|
||||
assert.equal(keyframeAtOrBefore([opening, middle, closing], 55), middle)
|
||||
})
|
||||
|
||||
it('falls back to the first keyframe before any marker', () => {
|
||||
assert.equal(keyframeAtOrBefore([middle, closing], 10), middle)
|
||||
})
|
||||
|
||||
it('returns undefined when there are no keyframes', () => {
|
||||
assert.equal(keyframeAtOrBefore([], 12), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withDraggedFrame', () => {
|
||||
const opening = keyframe(0, 'opening')
|
||||
const closing = keyframe(80, 'closing')
|
||||
|
||||
it('leaves committed positions alone when nothing is dragging', () => {
|
||||
const keyframes = [opening, closing]
|
||||
assert.equal(withDraggedFrame(keyframes, null), keyframes)
|
||||
})
|
||||
|
||||
it('overlays the in-flight frame on the dragged keyframe only', () => {
|
||||
const displayed = withDraggedFrame([opening, closing], { id: 'opening', frameIndex: 40 })
|
||||
assert.deepEqual(displayed.map(({ id, frameIndex }) => ({ id, frameIndex })), [
|
||||
{ id: 'opening', frameIndex: 40 },
|
||||
{ id: 'closing', frameIndex: 80 },
|
||||
])
|
||||
assert.equal(displayed[1], closing)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewKeyframeForPlayhead', () => {
|
||||
const opening = keyframe(0, 'opening')
|
||||
const closing = keyframe(80, 'closing')
|
||||
|
||||
it('keeps showing the dragged still after it passes a later committed marker', () => {
|
||||
const preview = previewKeyframeForPlayhead(
|
||||
[opening, closing],
|
||||
90,
|
||||
{ id: 'opening', frameIndex: 90 },
|
||||
)
|
||||
assert.equal(preview?.id, 'opening')
|
||||
})
|
||||
|
||||
it('uses committed positions when the pointer is not dragging', () => {
|
||||
assert.equal(previewKeyframeForPlayhead([opening, closing], 90, null)?.id, 'closing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameDraggedFrame', () => {
|
||||
it('treats identical id+frame overlays as unchanged so setState can bail out', () => {
|
||||
assert.equal(
|
||||
sameDraggedFrame({ id: 'opening', frameIndex: 12 }, { id: 'opening', frameIndex: 12 }),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
sameDraggedFrame({ id: 'opening', frameIndex: 12 }, { id: 'opening', frameIndex: 13 }),
|
||||
false,
|
||||
)
|
||||
assert.equal(sameDraggedFrame(null, null), true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Frame-grid math for multi-keyframe editing.
|
||||
*
|
||||
* Positions are integer frame indices in `0..lastFrame`. Two keyframes may
|
||||
* never share an index — every placement routes through pickFreeFrameIndex or
|
||||
* retimeKeyframes so collisions cannot silently drop images.
|
||||
*/
|
||||
|
||||
type Positioned = { frameIndex: number }
|
||||
|
||||
/**
|
||||
* Last legal keyframe index for a clip. Must match backend
|
||||
* ``frame_math.compute_num_frames(duration, fps) - 1`` — duration×fps rounded
|
||||
* to the pipeline's (n-1)%8==0 grid — or 25/50 fps stills can sit past the
|
||||
* generated clip and 422.
|
||||
*/
|
||||
export function lastFrameFromDuration(durationSeconds: number, fps: number): number {
|
||||
const frameCount = Math.max(9, Math.floor((durationSeconds * fps) / 8) * 8 + 1)
|
||||
return frameCount - 1
|
||||
}
|
||||
|
||||
export function clampFrameIndex(frameIndex: number, lastFrame: number): number {
|
||||
return Math.min(Math.max(0, Math.round(frameIndex)), Math.max(0, lastFrame))
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyframe on the playhead, else the last one before it — a gap shows the
|
||||
* image still standing rather than going blank. Unordered input is sorted.
|
||||
*/
|
||||
export function keyframeAtOrBefore<T extends { frameIndex: number }>(
|
||||
keyframes: readonly T[],
|
||||
frameIndex: number,
|
||||
): T | undefined {
|
||||
const ordered = [...keyframes].sort((a, b) => a.frameIndex - b.frameIndex)
|
||||
let match: T | undefined
|
||||
for (const keyframe of ordered) {
|
||||
if (keyframe.frameIndex > frameIndex) break
|
||||
match = keyframe
|
||||
}
|
||||
return match ?? ordered[0]
|
||||
}
|
||||
|
||||
export type DraggedFrame = {
|
||||
id: string
|
||||
frameIndex: number
|
||||
}
|
||||
|
||||
export function sameDraggedFrame(
|
||||
left: DraggedFrame | null | undefined,
|
||||
right: DraggedFrame | null | undefined,
|
||||
): boolean {
|
||||
return left?.id === right?.id && left?.frameIndex === right?.frameIndex
|
||||
}
|
||||
|
||||
export function withDraggedFrame<T extends { id: string; frameIndex: number }>(
|
||||
keyframes: readonly T[],
|
||||
drag: DraggedFrame | null,
|
||||
): readonly T[] {
|
||||
if (!drag) return keyframes
|
||||
return keyframes.map((keyframe) => (
|
||||
keyframe.id === drag.id && keyframe.frameIndex === drag.frameIndex
|
||||
? keyframe
|
||||
: keyframe.id === drag.id
|
||||
? { ...keyframe, frameIndex: drag.frameIndex }
|
||||
: keyframe
|
||||
))
|
||||
}
|
||||
|
||||
export function previewKeyframeForPlayhead<T extends { id: string; frameIndex: number }>(
|
||||
keyframes: readonly T[],
|
||||
playheadFrame: number,
|
||||
drag: DraggedFrame | null = null,
|
||||
): T | undefined {
|
||||
return keyframeAtOrBefore(withDraggedFrame(keyframes, drag), playheadFrame)
|
||||
}
|
||||
|
||||
/** `MM:SS.FF`, where `FF` is the frame within its second (`0..fps - 1`). */
|
||||
export function formatKeyframeTimecode(frameIndex: number, fps: number): string {
|
||||
const total = Math.max(0, Math.round(frameIndex))
|
||||
const frames = total % fps
|
||||
const totalSeconds = Math.floor(total / fps)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(minutes)}:${pad(seconds)}.${pad(frames)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `MM:SS.FF` (frames optional) into a frame index. Returns null for
|
||||
* anything unparseable or out of grid.
|
||||
*/
|
||||
export function parseKeyframeTimecode(value: string, fps: number): number | null {
|
||||
const match = /^(\d{1,2}):(\d{1,2})(?:\.(\d{1,2}))?$/.exec(value.trim())
|
||||
if (!match) return null
|
||||
|
||||
const minutes = Number(match[1])
|
||||
const seconds = Number(match[2])
|
||||
const frames = Number(match[3] ?? '0')
|
||||
if (seconds >= 60 || frames >= fps) return null
|
||||
|
||||
return (minutes * 60 + seconds) * fps + frames
|
||||
}
|
||||
|
||||
export function frameFromPointer(
|
||||
clientX: number,
|
||||
trackRect: { left: number; width: number },
|
||||
lastFrame: number,
|
||||
): number {
|
||||
if (lastFrame <= 0 || trackRect.width <= 0) return 0
|
||||
const ratio = (clientX - trackRect.left) / trackRect.width
|
||||
return clampFrameIndex(Math.round(ratio * lastFrame), lastFrame)
|
||||
}
|
||||
|
||||
function inRangeTakenFrameSet(keyframes: readonly Positioned[], lastFrame: number): Set<number> {
|
||||
return new Set(
|
||||
keyframes
|
||||
.map(({ frameIndex }) => frameIndex)
|
||||
.filter((frameIndex) => frameIndex >= 0 && frameIndex <= lastFrame),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to drop a new keyframe: the preferred frame when free, otherwise the
|
||||
* middle of the widest free stretch so burst adds spread across the timeline.
|
||||
*/
|
||||
export function pickFreeFrameIndex(
|
||||
keyframes: readonly Positioned[],
|
||||
lastFrame: number,
|
||||
preferredFrame: number,
|
||||
): number | null {
|
||||
const durationFrames = lastFrame + 1
|
||||
if (durationFrames <= 0) return null
|
||||
|
||||
const takenSet = inRangeTakenFrameSet(keyframes, lastFrame)
|
||||
if (takenSet.size >= durationFrames) return null
|
||||
|
||||
const clampedPreferred = clampFrameIndex(preferredFrame, lastFrame)
|
||||
if (!takenSet.has(clampedPreferred)) return clampedPreferred
|
||||
|
||||
let widest: { start: number; length: number } | undefined
|
||||
let runStart: number | undefined
|
||||
for (let frame = 0; frame <= durationFrames; frame++) {
|
||||
const isFree = frame < durationFrames && !takenSet.has(frame)
|
||||
if (isFree) {
|
||||
runStart ??= frame
|
||||
continue
|
||||
}
|
||||
if (runStart === undefined) continue
|
||||
const length = frame - runStart
|
||||
if (!widest || length > widest.length) widest = { start: runStart, length }
|
||||
runStart = undefined
|
||||
}
|
||||
|
||||
if (!widest) return null
|
||||
return widest.start + Math.floor((widest.length - 1) / 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a dragged or retyped keyframe lands: the target frame, or the closest
|
||||
* free one either side. Never jumps to an unrelated gap on the timeline.
|
||||
*/
|
||||
export function findNearestFreeFrameIndex(
|
||||
keyframes: readonly Positioned[],
|
||||
targetFrame: number,
|
||||
lastFrame: number,
|
||||
): number | null {
|
||||
const durationFrames = lastFrame + 1
|
||||
if (durationFrames <= 0) return null
|
||||
|
||||
const takenSet = inRangeTakenFrameSet(keyframes, lastFrame)
|
||||
const target = clampFrameIndex(targetFrame, lastFrame)
|
||||
for (let offset = 0; offset < durationFrames; offset++) {
|
||||
const before = target - offset
|
||||
const after = target + offset
|
||||
if (after <= lastFrame && !takenSet.has(after)) return after
|
||||
if (before >= 0 && !takenSet.has(before)) return before
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-grids keyframes after a frame-rate or duration change. Scales each
|
||||
* keyframe by oldLastFrame → newLastFrame so a marker at 50% of a 5s clip
|
||||
* stays at 50% of a 10s clip (and the reverse). Collisions still spread to
|
||||
* unique frames; extras past the slot count are dropped.
|
||||
*/
|
||||
export function retimeKeyframes<T extends { frameIndex: number }>(
|
||||
keyframes: readonly T[],
|
||||
oldLastFrame: number,
|
||||
newLastFrame: number,
|
||||
): T[] {
|
||||
const scale = oldLastFrame <= 0 ? 0 : newLastFrame / oldLastFrame
|
||||
|
||||
const targets = keyframes
|
||||
.map((keyframe, order) => ({
|
||||
keyframe,
|
||||
order,
|
||||
target: clampFrameIndex(keyframe.frameIndex * scale, newLastFrame),
|
||||
}))
|
||||
.sort((a, b) => a.target - b.target || a.order - b.order)
|
||||
|
||||
const positions = targets.map(({ target }) => target)
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
positions[i] = Math.max(positions[i], positions[i - 1] + 1)
|
||||
}
|
||||
let ceiling = newLastFrame
|
||||
for (let i = positions.length - 1; i >= 0; i--) {
|
||||
positions[i] = Math.max(0, Math.min(positions[i], ceiling))
|
||||
ceiling = positions[i] - 1
|
||||
}
|
||||
|
||||
const used = new Set<number>()
|
||||
const retimed: T[] = []
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const frameIndex = positions[i]
|
||||
if (used.has(frameIndex)) continue
|
||||
used.add(frameIndex)
|
||||
retimed.push({ ...targets[i].keyframe, frameIndex })
|
||||
}
|
||||
return retimed
|
||||
}
|
||||
|
||||
type TimelineSettings = {
|
||||
duration: number | null
|
||||
fps: number
|
||||
}
|
||||
|
||||
export function retimeKeyframesForSettings<T extends { frameIndex: number }>(
|
||||
keyframes: readonly T[],
|
||||
previous: TimelineSettings,
|
||||
next: TimelineSettings,
|
||||
): T[] {
|
||||
const fallbackDuration = previous.duration ?? next.duration
|
||||
const fpsRatio = next.fps / previous.fps
|
||||
const oldLastFrame = fallbackDuration === null
|
||||
? Math.max(0, ...keyframes.map(({ frameIndex }) => frameIndex))
|
||||
: lastFrameFromDuration(previous.duration ?? fallbackDuration, previous.fps)
|
||||
const newLastFrame = fallbackDuration === null
|
||||
? Math.max(0, ...keyframes.map(({ frameIndex }) => Math.round(frameIndex * fpsRatio)))
|
||||
: lastFrameFromDuration(next.duration ?? fallbackDuration, next.fps)
|
||||
|
||||
return retimeKeyframes(keyframes, oldLastFrame, newLastFrame)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
type LogLevel = 'INFO' | 'WARNING' | 'ERROR' | 'DEBUG'
|
||||
|
||||
function log(level: LogLevel, consoleMethod: 'log' | 'warn' | 'error', message: string): void {
|
||||
console[consoleMethod](message)
|
||||
window.electronAPI?.writeLog?.({ level, message })?.catch(() => {})
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
info: (message: string) => log('INFO', 'log', message),
|
||||
warn: (message: string) => log('WARNING', 'warn', message),
|
||||
error: (message: string) => log('ERROR', 'error', message),
|
||||
debug: (message: string) => log('DEBUG', 'log', message),
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
import type { ApiSuccessOf } from './api-client'
|
||||
import type { LoraCatalogListItem } from '../hooks/use-catalog'
|
||||
|
||||
type InstructionSection = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>[number]
|
||||
type LoraCatalogItem = components['schemas']['LoraCatalogItem']
|
||||
|
||||
// One row the library renders, normalized across both kinds and across catalog vs.
|
||||
// on-disk-only (manually-placed) items. Catalog-only fields (instructions/repoId/
|
||||
// requiresHfLogin/sizeBytes) are absent for on-disk-only files → card degrades to
|
||||
// filename + "Use". installedPath is set once an item is on disk (enables "Use").
|
||||
export interface LibraryEntry {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
sizeBytes?: number
|
||||
downloaded: boolean
|
||||
installedPath?: string
|
||||
// variant id → absolute/installed path for each on-disk checkpoint.
|
||||
variantInstalledPaths?: Record<string, string>
|
||||
recommendedStrength?: number
|
||||
instructions?: InstructionSection[]
|
||||
repoId?: string
|
||||
requiresHfLogin?: boolean
|
||||
thumbnailUrl?: string
|
||||
demoVideoUrl?: string
|
||||
author?: { name: string; url?: string | null; affiliation?: 'ltx' | 'community' }
|
||||
license?: { name: string; url?: string | null }
|
||||
// When length > 1, the library card shows a variant picker (download per checkpoint).
|
||||
variants?: { id: string; label: string; filename: string; sizeBytes: number }[]
|
||||
defaultVariantId?: string
|
||||
// Subset of variants[].id present on disk (API ∪ on-disk filename match).
|
||||
downloadedVariantIds?: string[]
|
||||
}
|
||||
|
||||
// Map a catalog item (plain or IC — IcLoraCatalogItem extends the base) to the shared
|
||||
// LibraryEntry fields. Callers add the per-kind bits (downloaded, installedPath).
|
||||
export function catalogItemToEntry(item: LoraCatalogItem): Omit<LibraryEntry, 'downloaded'> {
|
||||
// First variant is the catalog default (SSOT with backend DownloadSpec.default_variant()).
|
||||
const variants = item.download.variants.map(v => ({
|
||||
id: v.id,
|
||||
label: v.label,
|
||||
filename: v.filename,
|
||||
sizeBytes: v.size_bytes,
|
||||
}))
|
||||
const defaultVariant = variants[0]
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || undefined,
|
||||
sizeBytes: defaultVariant.sizeBytes,
|
||||
recommendedStrength: item.recommended_strength ?? undefined,
|
||||
instructions: item.instructions ?? undefined,
|
||||
repoId: item.download.repo_id,
|
||||
requiresHfLogin: item.requires_hf_login,
|
||||
thumbnailUrl: item.media?.thumbnail ?? undefined,
|
||||
demoVideoUrl: item.media?.demo_video ?? undefined,
|
||||
author: item.author ?? undefined,
|
||||
license: item.license ?? undefined,
|
||||
variants,
|
||||
defaultVariantId: defaultVariant.id,
|
||||
}
|
||||
}
|
||||
|
||||
// Shared catalog id ↔ variant encoding for selector values and download session keys.
|
||||
export const CATALOG_VARIANT_SEP = '::'
|
||||
|
||||
export function catalogVariantKey(catalogId: string, variantId?: string | null): string {
|
||||
return variantId ? `${catalogId}${CATALOG_VARIANT_SEP}${variantId}` : catalogId
|
||||
}
|
||||
|
||||
export function parseCatalogVariantKey(value: string): { catalogId: string; variantId?: string } {
|
||||
const sep = value.indexOf(CATALOG_VARIANT_SEP)
|
||||
if (sep === -1) return { catalogId: value }
|
||||
return { catalogId: value.slice(0, sep), variantId: value.slice(sep + CATALOG_VARIANT_SEP.length) }
|
||||
}
|
||||
|
||||
/** True when the selector should emit one option per installed variant. */
|
||||
export function hasMultiVariantSelector(
|
||||
variants: { id: string }[] | undefined,
|
||||
downloadedVariantIds: string[] | undefined,
|
||||
): boolean {
|
||||
return (variants?.length ?? 0) > 1 && (downloadedVariantIds?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
/** Encode selector value using the same rule as buildIcLoraSelectorOptions. */
|
||||
export function encodeIcLoraSelectorValue(
|
||||
catalogId: string,
|
||||
variantId: string | null | undefined,
|
||||
opts?: { variants?: { id: string }[]; downloadedVariantIds?: string[] },
|
||||
): string {
|
||||
if (opts && hasMultiVariantSelector(opts.variants, opts.downloadedVariantIds) && variantId) {
|
||||
return catalogVariantKey(catalogId, variantId)
|
||||
}
|
||||
return catalogId
|
||||
}
|
||||
|
||||
export function parseIcLoraSelectorValue(value: string): { catalogId: string; variantId?: string } {
|
||||
return parseCatalogVariantKey(value)
|
||||
}
|
||||
|
||||
export function buildIcLoraSelectorOptions(
|
||||
entries: {
|
||||
id: string
|
||||
name: string
|
||||
downloaded: boolean
|
||||
downloadedVariantIds?: string[]
|
||||
variants?: { id: string; label: string }[]
|
||||
}[],
|
||||
baseTypes: readonly { value: string; label: string }[],
|
||||
opts: { includeCatalog: boolean; includeCustom: boolean },
|
||||
): { value: string; label: string }[] {
|
||||
const custom = baseTypes.find(t => t.value === 'custom')
|
||||
const catalogOpts: { value: string; label: string }[] = []
|
||||
if (opts.includeCatalog) {
|
||||
for (const e of entries) {
|
||||
if (!e.downloaded) continue
|
||||
if (hasMultiVariantSelector(e.variants, e.downloadedVariantIds)) {
|
||||
const ready = new Set(e.downloadedVariantIds)
|
||||
for (const v of e.variants!) {
|
||||
if (!ready.has(v.id)) continue
|
||||
catalogOpts.push({
|
||||
value: catalogVariantKey(e.id, v.id),
|
||||
label: `${e.name} — ${v.label}`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
catalogOpts.push({ value: e.id, label: e.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
return [
|
||||
...baseTypes.filter(t => t.value !== 'custom').map(t => ({ value: t.value, label: t.label })),
|
||||
...catalogOpts,
|
||||
...(opts.includeCustom && custom ? [{ value: 'custom', label: custom.label }] : []),
|
||||
]
|
||||
}
|
||||
|
||||
const baseName = (p: string): string => p.split(/[\\/]/).pop() ?? p
|
||||
const normalizeSlashes = (p: string): string => p.replace(/\\/g, '/')
|
||||
|
||||
/** Prefer API-reported variant ids; also mark any variant whose filename is among installed basenames. */
|
||||
export function resolveDownloadedVariantIds(
|
||||
variants: { id: string; filename: string }[] | undefined,
|
||||
apiIds: string[] | undefined,
|
||||
installedPaths: string[],
|
||||
): string[] {
|
||||
const fromApi = apiIds ?? []
|
||||
if (!variants?.length) return fromApi
|
||||
const installedNames = new Set(installedPaths.map(baseName))
|
||||
const fromDisk = variants.filter(v => installedNames.has(v.filename)).map(v => v.id)
|
||||
return [...new Set([...fromApi, ...fromDisk])]
|
||||
}
|
||||
|
||||
/** Prefer the catalog default when installed; otherwise the first installed variant. Never an undownloaded id. */
|
||||
export function preferredVariantId(
|
||||
variants: { id: string }[] | undefined,
|
||||
defaultVariantId: string | undefined,
|
||||
downloadedVariantIds: string[] | undefined,
|
||||
): string | undefined {
|
||||
if (!variants?.length) return undefined
|
||||
const ready = new Set(downloadedVariantIds ?? [])
|
||||
if (ready.size === 0) return undefined
|
||||
if (defaultVariantId && ready.has(defaultVariantId)) return defaultVariantId
|
||||
return variants.find(v => ready.has(v.id))?.id
|
||||
}
|
||||
|
||||
/** Name for UI chips/pickers. Append ` — {label}` only when the catalog item has multiple variants. */
|
||||
export function variantDisplayName(
|
||||
name: string,
|
||||
variantLabel?: string | null,
|
||||
variantCount?: number,
|
||||
): string {
|
||||
if (!variantLabel || (variantCount !== undefined && variantCount <= 1)) return name
|
||||
return `${name} — ${variantLabel}`
|
||||
}
|
||||
|
||||
// Never substitutes a different variant's path: a caller asking for a specific variant
|
||||
// must get exactly that file or nothing (see use-lora-library's useEntry for the retry path).
|
||||
export function resolveInstalledPath(
|
||||
entry: LibraryEntry,
|
||||
variantId?: string | null,
|
||||
): string | undefined {
|
||||
if (!variantId) return entry.installedPath
|
||||
return entry.variantInstalledPaths?.[variantId]
|
||||
}
|
||||
|
||||
// Persisted LoRA refs (generationParams) are stored relative to modelsDir instead of
|
||||
// absolute so a project survives a reinstall or a move to another machine — modelsDir
|
||||
// varies per install. The backend resolves relative refs against modelsDir directly
|
||||
// (resolve_lora_ref), so no conversion back to absolute is needed to use one.
|
||||
export function toModelsDirRelativeRef(absPath: string, modelsDir: string): string {
|
||||
if (!modelsDir) return absPath
|
||||
const base = normalizeSlashes(modelsDir).replace(/\/+$/, '')
|
||||
const norm = normalizeSlashes(absPath)
|
||||
if (!norm.toLowerCase().startsWith(`${base.toLowerCase()}/`)) return absPath
|
||||
return norm.slice(base.length + 1)
|
||||
}
|
||||
|
||||
// Merge the plain-LoRA catalog with on-disk files into the library gallery list.
|
||||
// Cross-reference by download filename: a catalog entry carries its info + installed
|
||||
// path (if present). Installed catalog LoRAs appear once (enriched), never duplicated —
|
||||
// all variant files are marked matched. On-disk files with no catalog match are omitted
|
||||
// from the gallery; they remain available in the generation selector via listModels.
|
||||
export function mergeLoraLibrary(
|
||||
catalog: LoraCatalogListItem[],
|
||||
installed: ApiSuccessOf<'listModels'>['models'],
|
||||
): LibraryEntry[] {
|
||||
const byFilename = new Map(installed.map(m => [baseName(m.path), m]))
|
||||
const installedPaths = installed.map(m => m.path)
|
||||
return catalog.map(c => {
|
||||
const base = catalogItemToEntry(c.lora)
|
||||
const downloadedVariantIds = resolveDownloadedVariantIds(
|
||||
base.variants,
|
||||
c.downloaded_variant_ids,
|
||||
installedPaths,
|
||||
)
|
||||
const variantInstalledPaths: Record<string, string> = {}
|
||||
for (const v of base.variants ?? []) {
|
||||
const m = byFilename.get(v.filename)
|
||||
if (!m) continue
|
||||
variantInstalledPaths[v.id] = m.path
|
||||
}
|
||||
const preferredId = preferredVariantId(base.variants, base.defaultVariantId, downloadedVariantIds)
|
||||
const installedPath =
|
||||
(preferredId ? variantInstalledPaths[preferredId] : undefined)
|
||||
?? Object.values(variantInstalledPaths)[0]
|
||||
return {
|
||||
...base,
|
||||
downloaded: c.downloaded || downloadedVariantIds.length > 0 || Boolean(installedPath),
|
||||
downloadedVariantIds,
|
||||
variantInstalledPaths: Object.keys(variantInstalledPaths).length > 0 ? variantInstalledPaths : undefined,
|
||||
installedPath,
|
||||
sizeBytes: (installedPath ? byFilename.get(baseName(installedPath))?.size_bytes : undefined)
|
||||
?? base.sizeBytes,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { persistedKeyframeSchema } from '../types/project-model.ts'
|
||||
import { pickFreeFrameIndex } from './keyframe-timeline.ts'
|
||||
import {
|
||||
appendKeyframePaths,
|
||||
applyKeyframeImagePaths,
|
||||
DEFAULT_KEYFRAME_STRENGTH,
|
||||
MISSING_KEYFRAME_STRENGTH,
|
||||
fromPersistedKeyframes,
|
||||
toPersistedKeyframes,
|
||||
videoGenerationModeFromInputs,
|
||||
enhanceKeyframesPayload,
|
||||
type KeyframeItem,
|
||||
} from './multi-keyframe.ts'
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
path: string,
|
||||
frameIndex: number,
|
||||
strength = DEFAULT_KEYFRAME_STRENGTH,
|
||||
): KeyframeItem {
|
||||
return { id, path, frameIndex, strength }
|
||||
}
|
||||
|
||||
describe('rapid keyframe placement', () => {
|
||||
it('never assigns the same frame index twice', () => {
|
||||
const placed: { frameIndex: number }[] = []
|
||||
|
||||
for (let count = 0; count < 5; count++) {
|
||||
const frameIndex = pickFreeFrameIndex(placed, 120, 0)
|
||||
assert.notEqual(frameIndex, null)
|
||||
placed.push({ frameIndex: frameIndex! })
|
||||
}
|
||||
|
||||
assert.equal(new Set(placed.map(({ frameIndex }) => frameIndex)).size, placed.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendKeyframePaths', () => {
|
||||
it('spreads a burst of images across free frames', () => {
|
||||
let nextId = 0
|
||||
const added = appendKeyframePaths(
|
||||
[],
|
||||
['/a.png', '/b.png', '/c.png'],
|
||||
120,
|
||||
0,
|
||||
5,
|
||||
() => `id-${nextId++}`,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
added.map(({ path, frameIndex, strength }) => ({ path, frameIndex, strength })),
|
||||
[
|
||||
{ path: '/a.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ path: '/b.png', frameIndex: 60, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ path: '/c.png', frameIndex: 90, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
],
|
||||
)
|
||||
assert.equal(new Set(added.map(({ frameIndex }) => frameIndex)).size, 3)
|
||||
})
|
||||
|
||||
it('stops at the remaining cap', () => {
|
||||
const existing: KeyframeItem[] = [
|
||||
item('opening', '/opening.png', 0),
|
||||
item('closing', '/closing.png', 10),
|
||||
]
|
||||
|
||||
const added = appendKeyframePaths(
|
||||
existing,
|
||||
['/a.png', '/b.png', '/c.png'],
|
||||
20,
|
||||
0,
|
||||
3,
|
||||
() => 'extra',
|
||||
)
|
||||
|
||||
assert.equal(added.length, 3)
|
||||
assert.equal(added.at(-1)?.path, '/a.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyKeyframeImagePaths', () => {
|
||||
const existing: KeyframeItem[] = [
|
||||
item('opening', '/opening.png', 0),
|
||||
]
|
||||
|
||||
it('replaces one marker and appends leftover files', () => {
|
||||
let nextId = 0
|
||||
const next = applyKeyframeImagePaths({
|
||||
keyframes: existing,
|
||||
paths: ['/new-opening.png', '/middle.png'],
|
||||
replaceId: 'opening',
|
||||
lastFrame: 80,
|
||||
preferredFrame: 0,
|
||||
maxCount: 5,
|
||||
createId: () => `id-${nextId++}`,
|
||||
})
|
||||
|
||||
assert.equal(next[0]?.path, '/new-opening.png')
|
||||
assert.equal(next[0]?.frameIndex, 0)
|
||||
assert.equal(next[1]?.path, '/middle.png')
|
||||
assert.notEqual(next[1]?.frameIndex, 0)
|
||||
})
|
||||
|
||||
it('replaces without appending when duration is unknown', () => {
|
||||
const next = applyKeyframeImagePaths({
|
||||
keyframes: existing,
|
||||
paths: ['/new-opening.png', '/ignored.png'],
|
||||
replaceId: 'opening',
|
||||
lastFrame: null,
|
||||
preferredFrame: 0,
|
||||
maxCount: 5,
|
||||
})
|
||||
|
||||
assert.deepEqual(next, [item('opening', '/new-opening.png', 0)])
|
||||
})
|
||||
|
||||
it('keeps the still\'s owned strength when replacing its image', () => {
|
||||
const next = applyKeyframeImagePaths({
|
||||
keyframes: [item('opening', '/opening.png', 0, 0.7)],
|
||||
paths: ['/new-opening.png'],
|
||||
replaceId: 'opening',
|
||||
lastFrame: 80,
|
||||
preferredFrame: 0,
|
||||
maxCount: 5,
|
||||
})
|
||||
|
||||
assert.deepEqual(next, [item('opening', '/new-opening.png', 0, 0.7)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('persisted keyframes', () => {
|
||||
it('drops UI ids when snapshotting for generationParams', () => {
|
||||
assert.deepEqual(
|
||||
toPersistedKeyframes([
|
||||
item('opening', '/opening.png', 0),
|
||||
item('closing', '/closing.png', 80, 0.7),
|
||||
]),
|
||||
[
|
||||
{ path: '/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ path: '/closing.png', frameIndex: 80, strength: 0.7 },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('restores items with fresh ids and owned strength', () => {
|
||||
let nextId = 0
|
||||
const restored = fromPersistedKeyframes(
|
||||
[{ path: '/opening.png', frameIndex: 0, strength: 0.7 }],
|
||||
() => `id-${nextId++}`,
|
||||
)
|
||||
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, 0.7)])
|
||||
})
|
||||
|
||||
it('fills missing persisted strength with a full lock, not the new-still default', () => {
|
||||
let nextId = 0
|
||||
const restored = fromPersistedKeyframes(
|
||||
[{ path: '/opening.png', frameIndex: 0 }],
|
||||
() => `id-${nextId++}`,
|
||||
)
|
||||
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, MISSING_KEYFRAME_STRENGTH)])
|
||||
assert.notEqual(MISSING_KEYFRAME_STRENGTH, DEFAULT_KEYFRAME_STRENGTH)
|
||||
})
|
||||
|
||||
it('persists and restores a zero lock instead of treating it as missing', () => {
|
||||
assert.deepEqual(
|
||||
toPersistedKeyframes([item('opening', '/opening.png', 0, 0)]),
|
||||
[{ path: '/opening.png', frameIndex: 0, strength: 0 }],
|
||||
)
|
||||
let nextId = 0
|
||||
const restored = fromPersistedKeyframes(
|
||||
[{ path: '/opening.png', frameIndex: 0, strength: 0 }],
|
||||
() => `id-${nextId++}`,
|
||||
)
|
||||
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, 0)])
|
||||
})
|
||||
|
||||
it('clamps restored strength to the 0-1 lock range', () => {
|
||||
let nextId = 0
|
||||
const restored = fromPersistedKeyframes(
|
||||
[
|
||||
{ path: '/hi.png', frameIndex: 0, strength: 1.5 },
|
||||
{ path: '/lo.png', frameIndex: 40, strength: -0.2 },
|
||||
],
|
||||
() => `id-${nextId++}`,
|
||||
)
|
||||
assert.deepEqual(restored, [
|
||||
item('id-0', '/hi.png', 0, 1),
|
||||
item('id-1', '/lo.png', 40, 0),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('videoGenerationModeFromInputs', () => {
|
||||
it('prefers multi-keyframe over image and audio inputs', () => {
|
||||
assert.equal(
|
||||
videoGenerationModeFromInputs({
|
||||
keyframes: [{ path: '/opening.png', frameIndex: 0 }],
|
||||
audioUrl: '/clip.mp3',
|
||||
imageUrl: '/still.png',
|
||||
}),
|
||||
'multi-keyframe',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to text-to-video when nothing is attached', () => {
|
||||
assert.equal(videoGenerationModeFromInputs({}), 'text-to-video')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enhanceKeyframesPayload', () => {
|
||||
it('returns undefined when there are no stills', () => {
|
||||
assert.equal(enhanceKeyframesPayload([]), undefined)
|
||||
})
|
||||
|
||||
it('maps a single still onto the enhance keyframe list', () => {
|
||||
assert.deepEqual(
|
||||
enhanceKeyframesPayload([item('opening', '/opening.png', 40)]),
|
||||
[{ imagePath: '/opening.png', frameIndex: 40, strength: DEFAULT_KEYFRAME_STRENGTH }],
|
||||
)
|
||||
})
|
||||
|
||||
it('sends every still, including middle markers, in frame order', () => {
|
||||
assert.deepEqual(
|
||||
enhanceKeyframesPayload([
|
||||
item('closing', '/closing.png', 80),
|
||||
item('opening', '/opening.png', 0),
|
||||
item('middle', '/middle.png', 40),
|
||||
]),
|
||||
[
|
||||
{ imagePath: '/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ imagePath: '/middle.png', frameIndex: 40, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ imagePath: '/closing.png', frameIndex: 80, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards each still\'s owned strength instead of hardcoding the default', () => {
|
||||
assert.deepEqual(
|
||||
enhanceKeyframesPayload([item('opening', '/opening.png', 0, 0.7)]),
|
||||
[{ imagePath: '/opening.png', frameIndex: 0, strength: 0.7 }],
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards a zero lock instead of treating it as missing', () => {
|
||||
assert.deepEqual(
|
||||
enhanceKeyframesPayload([item('opening', '/opening.png', 0, 0)]),
|
||||
[{ imagePath: '/opening.png', frameIndex: 0, strength: 0 }],
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps out-of-range strength on the enhance payload', () => {
|
||||
assert.deepEqual(
|
||||
enhanceKeyframesPayload([
|
||||
item('hi', '/hi.png', 0, 1.5),
|
||||
item('lo', '/lo.png', 40, -0.2),
|
||||
]),
|
||||
[
|
||||
{ imagePath: '/hi.png', frameIndex: 0, strength: 1 },
|
||||
{ imagePath: '/lo.png', frameIndex: 40, strength: 0 },
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistedKeyframeSchema', () => {
|
||||
it('defaults missing strength to a full lock', () => {
|
||||
assert.deepEqual(
|
||||
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0 }),
|
||||
{ path: '/opening.png', frameIndex: 0, strength: MISSING_KEYFRAME_STRENGTH },
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a zero lock and clamps out of range so project parse cannot fail', () => {
|
||||
assert.equal(
|
||||
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: 0 }).strength,
|
||||
0,
|
||||
)
|
||||
assert.equal(
|
||||
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: 1.5 }).strength,
|
||||
1,
|
||||
)
|
||||
assert.equal(
|
||||
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: -0.2 }).strength,
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { clampKeyframeStrength, DEFAULT_KEYFRAME_STRENGTH } from './keyframe-strength.ts'
|
||||
import { pickFreeFrameIndex } from './keyframe-timeline.ts'
|
||||
|
||||
export {
|
||||
clampKeyframeStrength,
|
||||
DEFAULT_KEYFRAME_STRENGTH,
|
||||
MISSING_KEYFRAME_STRENGTH,
|
||||
} from './keyframe-strength.ts'
|
||||
|
||||
/** Local Distilled cap. Must match backend LOCAL_MULTI_KEYFRAME_MAX_COUNT. API stays 0. */
|
||||
export const LOCAL_MULTI_KEYFRAME_MAX_COUNT = 10
|
||||
|
||||
export interface KeyframeItem {
|
||||
id: string
|
||||
path: string
|
||||
frameIndex: number
|
||||
strength: number
|
||||
}
|
||||
|
||||
export function appendKeyframePaths(
|
||||
keyframes: readonly KeyframeItem[],
|
||||
paths: readonly string[],
|
||||
lastFrame: number,
|
||||
preferredFrame: number,
|
||||
maxCount: number,
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
): KeyframeItem[] {
|
||||
const next = [...keyframes]
|
||||
for (const path of paths) {
|
||||
if (next.length >= maxCount) break
|
||||
const frameIndex = pickFreeFrameIndex(next, lastFrame, preferredFrame)
|
||||
if (frameIndex === null) break
|
||||
next.push({ id: createId(), path, frameIndex, strength: DEFAULT_KEYFRAME_STRENGTH })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function applyKeyframeImagePaths({
|
||||
keyframes,
|
||||
paths,
|
||||
replaceId,
|
||||
lastFrame,
|
||||
preferredFrame,
|
||||
maxCount,
|
||||
createId,
|
||||
}: {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
paths: readonly string[]
|
||||
replaceId: string | null
|
||||
lastFrame: number | null
|
||||
preferredFrame: number
|
||||
maxCount: number
|
||||
createId?: () => string
|
||||
}): KeyframeItem[] {
|
||||
if (paths.length === 0) return [...keyframes]
|
||||
|
||||
let next = [...keyframes]
|
||||
let remaining = paths
|
||||
if (replaceId) {
|
||||
const [first, ...rest] = paths
|
||||
next = next.map((keyframe) => (
|
||||
keyframe.id === replaceId ? { ...keyframe, path: first } : keyframe
|
||||
))
|
||||
remaining = rest
|
||||
}
|
||||
|
||||
if (lastFrame === null || remaining.length === 0) return next
|
||||
return appendKeyframePaths(
|
||||
next,
|
||||
remaining,
|
||||
lastFrame,
|
||||
preferredFrame,
|
||||
maxCount,
|
||||
createId,
|
||||
)
|
||||
}
|
||||
|
||||
export type PersistedKeyframe = {
|
||||
path: string
|
||||
frameIndex: number
|
||||
strength: number
|
||||
}
|
||||
|
||||
export function toPersistedKeyframes(
|
||||
keyframes: readonly KeyframeItem[],
|
||||
): PersistedKeyframe[] {
|
||||
return keyframes.map(({ path, frameIndex, strength }) => ({
|
||||
path,
|
||||
frameIndex,
|
||||
strength: clampKeyframeStrength(strength),
|
||||
}))
|
||||
}
|
||||
|
||||
export function fromPersistedKeyframes(
|
||||
keyframes: readonly { path: string; frameIndex: number; strength?: number }[],
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
): KeyframeItem[] {
|
||||
return keyframes.map(({ path, frameIndex, strength }) => ({
|
||||
id: createId(),
|
||||
path,
|
||||
frameIndex,
|
||||
strength: clampKeyframeStrength(strength),
|
||||
}))
|
||||
}
|
||||
|
||||
export function videoGenerationModeFromInputs({
|
||||
keyframes,
|
||||
audioUrl,
|
||||
imageUrl,
|
||||
}: {
|
||||
keyframes?: readonly unknown[] | null
|
||||
audioUrl?: string | null
|
||||
imageUrl?: string | null
|
||||
}): 'multi-keyframe' | 'audio-to-video' | 'image-to-video' | 'text-to-video' {
|
||||
if (keyframes && keyframes.length > 0) return 'multi-keyframe'
|
||||
if (audioUrl) return 'audio-to-video'
|
||||
if (imageUrl) return 'image-to-video'
|
||||
return 'text-to-video'
|
||||
}
|
||||
|
||||
export function toKeyframeInputs(
|
||||
keyframes: readonly Pick<KeyframeItem, 'path' | 'frameIndex' | 'strength'>[],
|
||||
): { imagePath: string; frameIndex: number; strength: number }[] {
|
||||
return keyframes.map(({ path, frameIndex, strength }) => ({
|
||||
imagePath: path,
|
||||
frameIndex,
|
||||
strength: clampKeyframeStrength(strength),
|
||||
}))
|
||||
}
|
||||
|
||||
export function enhanceKeyframesPayload(
|
||||
keyframes: readonly Pick<KeyframeItem, 'path' | 'frameIndex' | 'strength'>[],
|
||||
): { imagePath: string; frameIndex: number; strength: number }[] | undefined {
|
||||
if (keyframes.length === 0) return undefined
|
||||
return toKeyframeInputs([...keyframes].sort((a, b) => a.frameIndex - b.frameIndex))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { ElectronAPI } from '../../shared/electron-api-schema'
|
||||
import type { Asset } from '../types/project-model'
|
||||
import { logger } from './logger'
|
||||
|
||||
interface VisualAssetMetadataMigrationJob {
|
||||
path: string
|
||||
type: 'video' | 'image'
|
||||
needsThumbnails: boolean
|
||||
needsDimensions: boolean
|
||||
}
|
||||
|
||||
interface ThumbnailPaths {
|
||||
bigThumbnailPath: string
|
||||
smallThumbnailPath: string
|
||||
}
|
||||
|
||||
interface VisualAssetDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface VisualAssetMigrationResult extends Partial<ThumbnailPaths>, Partial<VisualAssetDimensions> {}
|
||||
|
||||
export interface VisualAssetMetadataMigrationUpdate {
|
||||
assetId: string
|
||||
updates: Partial<Asset>
|
||||
}
|
||||
|
||||
export type VisualAssetMetadataMigrationEvent =
|
||||
| {
|
||||
kind: 'progress'
|
||||
total: number
|
||||
completed: number
|
||||
}
|
||||
| {
|
||||
kind: 'complete'
|
||||
total: number
|
||||
completed: number
|
||||
updates: VisualAssetMetadataMigrationUpdate[]
|
||||
}
|
||||
|
||||
function isVisualAsset(asset: Asset): asset is Asset & { type: 'video' | 'image' } {
|
||||
return asset.type === 'video' || asset.type === 'image'
|
||||
}
|
||||
|
||||
function isMissingThumbnailPair(item: { bigThumbnailPath?: string; smallThumbnailPath?: string }): boolean {
|
||||
return !item.bigThumbnailPath || !item.smallThumbnailPath
|
||||
}
|
||||
|
||||
function isMissingDimensions(item: { width?: number; height?: number }): boolean {
|
||||
return !item.width || !item.height
|
||||
}
|
||||
|
||||
function collectVisualAssetMetadataMigrationJobs(assets: Asset[]): VisualAssetMetadataMigrationJob[] {
|
||||
const jobs = new Map<string, VisualAssetMetadataMigrationJob>()
|
||||
|
||||
for (const asset of assets) {
|
||||
if (!isVisualAsset(asset)) continue
|
||||
|
||||
if (asset.path && (isMissingThumbnailPair(asset) || isMissingDimensions(asset))) {
|
||||
const existingJob = jobs.get(asset.path)
|
||||
jobs.set(asset.path, {
|
||||
path: asset.path,
|
||||
type: asset.type,
|
||||
needsThumbnails: (existingJob?.needsThumbnails || false) || isMissingThumbnailPair(asset),
|
||||
needsDimensions: (existingJob?.needsDimensions || false) || isMissingDimensions(asset),
|
||||
})
|
||||
}
|
||||
|
||||
for (const take of asset.takes ?? []) {
|
||||
if (take.path && (isMissingThumbnailPair(take) || isMissingDimensions(take))) {
|
||||
const existingJob = jobs.get(take.path)
|
||||
jobs.set(take.path, {
|
||||
path: take.path,
|
||||
type: asset.type,
|
||||
needsThumbnails: (existingJob?.needsThumbnails || false) || isMissingThumbnailPair(take),
|
||||
needsDimensions: (existingJob?.needsDimensions || false) || isMissingDimensions(take),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(jobs.values())
|
||||
}
|
||||
|
||||
function buildVisualAssetMetadataMigrationPatch(
|
||||
asset: Asset,
|
||||
migrationResults: Map<string, VisualAssetMigrationResult>,
|
||||
): Partial<Asset> | null {
|
||||
if (!isVisualAsset(asset)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updates: Partial<Asset> = {}
|
||||
const assetMetadata = migrationResults.get(asset.path)
|
||||
|
||||
if (assetMetadata?.bigThumbnailPath && asset.bigThumbnailPath !== assetMetadata.bigThumbnailPath) {
|
||||
updates.bigThumbnailPath = assetMetadata.bigThumbnailPath
|
||||
}
|
||||
if (assetMetadata?.smallThumbnailPath && asset.smallThumbnailPath !== assetMetadata.smallThumbnailPath) {
|
||||
updates.smallThumbnailPath = assetMetadata.smallThumbnailPath
|
||||
}
|
||||
if (assetMetadata?.width && asset.width !== assetMetadata.width) {
|
||||
updates.width = assetMetadata.width
|
||||
}
|
||||
if (assetMetadata?.height && asset.height !== assetMetadata.height) {
|
||||
updates.height = assetMetadata.height
|
||||
}
|
||||
|
||||
if (asset.takes && asset.takes.length > 0) {
|
||||
let takesChanged = false
|
||||
const nextTakes = asset.takes.map(take => {
|
||||
const takeMetadata = migrationResults.get(take.path)
|
||||
if (!takeMetadata) {
|
||||
return take
|
||||
}
|
||||
|
||||
if (
|
||||
(takeMetadata.bigThumbnailPath === undefined || take.bigThumbnailPath === takeMetadata.bigThumbnailPath)
|
||||
&& (takeMetadata.smallThumbnailPath === undefined || take.smallThumbnailPath === takeMetadata.smallThumbnailPath)
|
||||
&& (takeMetadata.width === undefined || take.width === takeMetadata.width)
|
||||
&& (takeMetadata.height === undefined || take.height === takeMetadata.height)
|
||||
) {
|
||||
return take
|
||||
}
|
||||
|
||||
takesChanged = true
|
||||
return {
|
||||
...take,
|
||||
...(takeMetadata.bigThumbnailPath ? { bigThumbnailPath: takeMetadata.bigThumbnailPath } : {}),
|
||||
...(takeMetadata.smallThumbnailPath ? { smallThumbnailPath: takeMetadata.smallThumbnailPath } : {}),
|
||||
...(takeMetadata.width ? { width: takeMetadata.width } : {}),
|
||||
...(takeMetadata.height ? { height: takeMetadata.height } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
if (takesChanged) {
|
||||
updates.takes = nextTakes
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? updates : null
|
||||
}
|
||||
|
||||
export function hasVisualAssetMetadataForMigration(assets: Asset[]): boolean {
|
||||
return assets.some(asset => {
|
||||
if (!isVisualAsset(asset)) return false
|
||||
if (isMissingThumbnailPair(asset) || isMissingDimensions(asset)) return true
|
||||
return (asset.takes ?? []).some(take => isMissingThumbnailPair(take) || isMissingDimensions(take))
|
||||
})
|
||||
}
|
||||
|
||||
export async function* runVisualAssetMetadataMigration(
|
||||
assets: Asset[],
|
||||
electronAPI: Pick<ElectronAPI, 'makeThumbnailsForProjectAsset' | 'makeDimensionsForProjectAsset'>,
|
||||
): AsyncGenerator<VisualAssetMetadataMigrationEvent> {
|
||||
const jobs = collectVisualAssetMetadataMigrationJobs(assets)
|
||||
|
||||
if (jobs.length === 0) {
|
||||
yield {
|
||||
kind: 'complete',
|
||||
total: 0,
|
||||
completed: 0,
|
||||
updates: [],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield {
|
||||
kind: 'progress',
|
||||
total: jobs.length,
|
||||
completed: 0,
|
||||
}
|
||||
|
||||
const migrationResults = new Map<string, VisualAssetMigrationResult>()
|
||||
let completed = 0
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const nextResult: VisualAssetMigrationResult = {}
|
||||
|
||||
if (job.needsThumbnails) {
|
||||
const thumbnailResult = await electronAPI.makeThumbnailsForProjectAsset({
|
||||
path: job.path,
|
||||
type: job.type,
|
||||
})
|
||||
if (thumbnailResult.success) {
|
||||
nextResult.bigThumbnailPath = thumbnailResult.bigThumbnailPath
|
||||
nextResult.smallThumbnailPath = thumbnailResult.smallThumbnailPath
|
||||
} else {
|
||||
logger.warn(`Thumbnail migration skipped for ${job.path}: ${thumbnailResult.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (job.needsDimensions) {
|
||||
const dimensionsResult = await electronAPI.makeDimensionsForProjectAsset({
|
||||
path: job.path,
|
||||
type: job.type,
|
||||
})
|
||||
if (dimensionsResult.success) {
|
||||
nextResult.width = dimensionsResult.width
|
||||
nextResult.height = dimensionsResult.height
|
||||
} else {
|
||||
logger.warn(`Dimensions migration skipped for ${job.path}: ${dimensionsResult.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextResult).length > 0) {
|
||||
migrationResults.set(job.path, nextResult)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Asset metadata migration skipped for ${job.path}: ${error}`)
|
||||
}
|
||||
|
||||
completed += 1
|
||||
yield {
|
||||
kind: 'progress',
|
||||
total: jobs.length,
|
||||
completed,
|
||||
}
|
||||
}
|
||||
|
||||
const updates: VisualAssetMetadataMigrationUpdate[] = []
|
||||
for (const asset of assets) {
|
||||
const assetUpdates = buildVisualAssetMetadataMigrationPatch(asset, migrationResults)
|
||||
if (assetUpdates) {
|
||||
updates.push({
|
||||
assetId: asset.id,
|
||||
updates: assetUpdates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
kind: 'complete',
|
||||
total: jobs.length,
|
||||
completed,
|
||||
updates,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
deleteAllProjectEntries,
|
||||
deleteProjectIdsEntry,
|
||||
hasLegacyProjectsEntry,
|
||||
readLegacyProjects,
|
||||
readProjectsFromReferences,
|
||||
writeLegacyProjects,
|
||||
} from '../hooks/useProjectReferencesMigration'
|
||||
|
||||
export type RollbackProjectReferencesMigrationOptions = {
|
||||
reload?: boolean
|
||||
}
|
||||
|
||||
export type RollbackProjectReferencesMigrationResult =
|
||||
| {
|
||||
status: 'alreadyLegacy'
|
||||
projectCount: number
|
||||
}
|
||||
| {
|
||||
status: 'rolledBack'
|
||||
projectCount: number
|
||||
projectIds: string[]
|
||||
}
|
||||
|
||||
function rollbackProjectReferencesMigration(
|
||||
options: RollbackProjectReferencesMigrationOptions = {},
|
||||
): RollbackProjectReferencesMigrationResult {
|
||||
if (hasLegacyProjectsEntry()) {
|
||||
const legacyProjects = readLegacyProjects()
|
||||
if (options.reload) {
|
||||
window.location.reload()
|
||||
}
|
||||
return {
|
||||
status: 'alreadyLegacy',
|
||||
projectCount: legacyProjects.length,
|
||||
}
|
||||
}
|
||||
|
||||
const projects = readProjectsFromReferences()
|
||||
const restoredProjects = writeLegacyProjects(projects)
|
||||
deleteProjectIdsEntry()
|
||||
deleteAllProjectEntries()
|
||||
|
||||
if (options.reload) {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'rolledBack',
|
||||
projectCount: restoredProjects.length,
|
||||
projectIds: restoredProjects.map(project => project.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function installProjectStorageDevtools(): void {
|
||||
if (!import.meta.env.DEV) return
|
||||
|
||||
window.__ltxProjectStorageDebug = {
|
||||
rollbackProjectReferencesMigration,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { migrateProjectData, projectSchema, type Project } from '../types/project-model'
|
||||
import { logger } from './logger'
|
||||
|
||||
export const PROJECT_IDS_STORAGE_KEY = 'ltx-project-ids'
|
||||
export const PROJECT_STORAGE_KEY_PREFIX = 'ltx-project-'
|
||||
|
||||
export function getProjectStorageKey(projectId: string): string {
|
||||
return `${PROJECT_STORAGE_KEY_PREFIX}${projectId}`
|
||||
}
|
||||
|
||||
export function readProjectIds(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(PROJECT_IDS_STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
|
||||
const parsed = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger.error('Project ids payload is not an array')
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed.filter((projectId): projectId is string => typeof projectId === 'string')
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read project ids: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProjectIds(projectIds: string[]): void {
|
||||
localStorage.setItem(
|
||||
PROJECT_IDS_STORAGE_KEY,
|
||||
JSON.stringify(Array.from(new Set(projectIds))),
|
||||
)
|
||||
}
|
||||
|
||||
export function readProject(projectId: string): Project | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(getProjectStorageKey(projectId))
|
||||
if (!stored) return null
|
||||
|
||||
const { project, migrated } = migrateProjectData(JSON.parse(stored))
|
||||
const normalizedProject = project.id === projectId
|
||||
? project
|
||||
: projectSchema.parse({ ...project, id: projectId })
|
||||
|
||||
if (migrated || normalizedProject.id !== project.id) {
|
||||
writeProject(projectId, normalizedProject)
|
||||
}
|
||||
|
||||
return normalizedProject
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read project ${projectId}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProject(projectId: string, project: Project): Project {
|
||||
const normalizedProject = projectSchema.parse({ ...project, id: projectId })
|
||||
localStorage.setItem(
|
||||
getProjectStorageKey(projectId),
|
||||
JSON.stringify(normalizedProject),
|
||||
)
|
||||
return normalizedProject
|
||||
}
|
||||
|
||||
export function deleteProjectEntry(projectId: string): void {
|
||||
localStorage.removeItem(getProjectStorageKey(projectId))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeResponseError } from './response-error.ts'
|
||||
test('error presentation accepts native, FastAPI, Deck and web-service errors', () => {
|
||||
for (const input of [{code:'OOM',message:'CUDA OOM'},{detail:'Offline'},{error:'Sign in'},{error:{message:'Activate Video'}},null,[],{detail:[{msg:'invalid'}]}]) {
|
||||
const value=normalizeResponseError(input)
|
||||
assert.equal(typeof value.message,'string');assert.ok(value.message.length)
|
||||
}
|
||||
assert.equal(normalizeResponseError({error:{message:'Activate Video'}}).message,'Activate Video')
|
||||
assert.equal(normalizeResponseError({code:'OOM',message:'CUDA OOM'}).code,'OOM')
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// UI presentation only: the web proxy preserves the native HTTP response unchanged.
|
||||
// Gateways and unavailable-backend responses need not use LTX's error schema.
|
||||
export function normalizeResponseError(payload: unknown): { code: string; message: string; [key: string]: unknown } {
|
||||
const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
||||
const nested = record.error && typeof record.error === 'object' ? record.error as Record<string, unknown> : {}
|
||||
const message = [record.message, nested.message, record.error, record.detail, payload].find(value => typeof value === 'string' && value.length > 0)
|
||||
return { ...record, code: typeof record.code === 'string' ? record.code : 'HTTP_ERROR', message: typeof message === 'string' ? message : 'The backend rejected this request. See technical details.' }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// SRT subtitle format parsing and export utilities
|
||||
|
||||
export interface SrtCue {
|
||||
index: number
|
||||
startTime: number // in seconds
|
||||
endTime: number // in seconds
|
||||
text: string
|
||||
color?: string // extracted from <font color=...> tags if present
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SRT timestamp to seconds
|
||||
* Format: HH:MM:SS,mmm (e.g. "00:01:23,456")
|
||||
*/
|
||||
function parseTimestamp(ts: string): number {
|
||||
const match = ts.trim().match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/)
|
||||
if (!match) return 0
|
||||
const [, h, m, s, ms] = match
|
||||
return parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s) + parseInt(ms) / 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert seconds to SRT timestamp
|
||||
* Returns format: HH:MM:SS,mmm
|
||||
*/
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.round((seconds % 1) * 1000)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML/rich-text tags from SRT text.
|
||||
* Handles <font color=...>, <b>, <i>, <u>, and any other HTML tags.
|
||||
* Extracts the first color value found (if any).
|
||||
*/
|
||||
function stripTags(text: string): { clean: string; color?: string } {
|
||||
let color: string | undefined
|
||||
|
||||
// Extract color from <font color=...> (Premiere format: color=#RRGGBBAA or #RRGGBB)
|
||||
const colorMatch = text.match(/<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/i)
|
||||
if (colorMatch) {
|
||||
let c = colorMatch[1].trim()
|
||||
// Premiere sometimes outputs 8-char hex (#RRGGBBAA) — convert to standard 6-char
|
||||
if (/^#[0-9A-Fa-f]{8}$/.test(c)) {
|
||||
c = c.slice(0, 7) // drop the alpha suffix
|
||||
}
|
||||
color = c
|
||||
}
|
||||
|
||||
// Strip all HTML tags
|
||||
const clean = text
|
||||
.replace(/<[^>]+>/g, '') // remove tags
|
||||
.replace(/\n\s*\n/g, '\n') // collapse blank lines left by removed tags
|
||||
.trim()
|
||||
|
||||
return { clean, color }
|
||||
}
|
||||
|
||||
// Threshold: cues shorter than this (in seconds) are considered "pre-cues" / fade markers
|
||||
const PRE_CUE_THRESHOLD = 0.1 // 100ms
|
||||
|
||||
/**
|
||||
* Parse an SRT file content string into an array of cues.
|
||||
*
|
||||
* Handles:
|
||||
* - Standard SRT format
|
||||
* - Premiere Pro SRT with <font color=...> tags
|
||||
* - Premiere "pre-cue" pairs (near-zero-duration fade-in marker + real cue)
|
||||
* → merged into a single cue using the pre-cue's start time and the real cue's end time
|
||||
*/
|
||||
export function parseSrt(content: string): SrtCue[] {
|
||||
const rawCues: SrtCue[] = []
|
||||
|
||||
// Normalize line endings
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
||||
|
||||
// Split into blocks separated by empty lines
|
||||
const blocks = normalized.split(/\n\n+/)
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n')
|
||||
if (lines.length < 3) continue
|
||||
|
||||
// First line: index number
|
||||
const index = parseInt(lines[0].trim())
|
||||
if (isNaN(index)) continue
|
||||
|
||||
// Second line: timestamps (start --> end)
|
||||
const timeParts = lines[1].split('-->')
|
||||
if (timeParts.length !== 2) continue
|
||||
|
||||
const startTime = parseTimestamp(timeParts[0])
|
||||
const endTime = parseTimestamp(timeParts[1])
|
||||
|
||||
if (endTime <= startTime) continue
|
||||
|
||||
// Remaining lines: subtitle text (strip HTML tags)
|
||||
const rawText = lines.slice(2).join('\n').trim()
|
||||
if (!rawText) continue
|
||||
|
||||
const { clean, color } = stripTags(rawText)
|
||||
if (!clean) continue
|
||||
|
||||
rawCues.push({ index, startTime, endTime, text: clean, color })
|
||||
}
|
||||
|
||||
// --- Merge Premiere-style pre-cue pairs ---
|
||||
// Pattern: a near-zero-duration cue immediately followed by a cue with the same text.
|
||||
// The first cue's start time is the real start; the second cue's end time is the real end.
|
||||
const merged: SrtCue[] = []
|
||||
let i = 0
|
||||
while (i < rawCues.length) {
|
||||
const cur = rawCues[i]
|
||||
const next = rawCues[i + 1]
|
||||
|
||||
const curDuration = cur.endTime - cur.startTime
|
||||
|
||||
if (
|
||||
next &&
|
||||
curDuration <= PRE_CUE_THRESHOLD &&
|
||||
cur.text === next.text &&
|
||||
Math.abs(cur.endTime - next.startTime) < 0.1 // consecutive
|
||||
) {
|
||||
// Merge: use pre-cue's start + real cue's end
|
||||
merged.push({
|
||||
index: cur.index,
|
||||
startTime: cur.startTime,
|
||||
endTime: next.endTime,
|
||||
text: cur.text,
|
||||
color: cur.color || next.color,
|
||||
})
|
||||
i += 2 // skip both
|
||||
} else if (curDuration <= PRE_CUE_THRESHOLD) {
|
||||
// Standalone near-zero cue with no matching follow-up — skip it (likely orphan pre-cue)
|
||||
i++
|
||||
} else {
|
||||
merged.push(cur)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Re-index
|
||||
return merged.map((cue, idx) => ({ ...cue, index: idx + 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an array of cues to SRT format string
|
||||
*/
|
||||
export function exportSrt(cues: { startTime: number; endTime: number; text: string }[]): string {
|
||||
// Sort by start time
|
||||
const sorted = [...cues].sort((a, b) => a.startTime - b.startTime)
|
||||
|
||||
return sorted.map((cue, i) => {
|
||||
return `${i + 1}\n${formatTimestamp(cue.startTime)} --> ${formatTimestamp(cue.endTime)}\n${cue.text}`
|
||||
}).join('\n\n') + '\n'
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
// Persisted "don't show the LTX upgrade prompt again" set, keyed by target model id.
|
||||
// Best-effort: if localStorage is unavailable the prompt simply reappears next session.
|
||||
const KEY = 'ltxDismissedUpgrades'
|
||||
|
||||
function read(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function isUpgradeDismissed(modelId: string): boolean {
|
||||
return read().includes(modelId)
|
||||
}
|
||||
|
||||
export function dismissUpgrade(modelId: string): void {
|
||||
try {
|
||||
const ids = read()
|
||||
if (!ids.includes(modelId)) {
|
||||
localStorage.setItem(KEY, JSON.stringify([...ids, modelId]))
|
||||
}
|
||||
} catch {
|
||||
// ignore — persistence is best-effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Client-side guard mirroring the LTX API's input constraints, so we reject bad source
|
||||
// videos instantly instead of after a slow upload + API round-trip. Dimensions come from
|
||||
// the <video> element (videoWidth/videoHeight); the backend/API remain the authority.
|
||||
//
|
||||
// No aspect-ratio check: the API accepts near-16:9 sizes the docs don't list (e.g.
|
||||
// 1920x1024) and ltx-studio doesn't pre-check aspect either, so a strict check
|
||||
// false-rejects working videos. The API stays the authority for aspect.
|
||||
|
||||
export interface VideoDimensions {
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
// API: "Maximum resolution 3840x2160 (4K)", "Minimum frame count 73 (~3s at 24fps)".
|
||||
const MAX_LONG_EDGE = 3840
|
||||
const MAX_SHORT_EDGE = 2160
|
||||
const MIN_DURATION_S = 3
|
||||
|
||||
export function validateVideoSource({ width, height, duration }: VideoDimensions): string | null {
|
||||
// Dimensions not known yet (metadata still loading) — don't block prematurely.
|
||||
if (!width || !height) return null
|
||||
const longEdge = Math.max(width, height)
|
||||
const shortEdge = Math.min(width, height)
|
||||
if (longEdge > MAX_LONG_EDGE || shortEdge > MAX_SHORT_EDGE) {
|
||||
return `Video is larger than 4K (${width}×${height}). Maximum supported is 3840×2160.`
|
||||
}
|
||||
if (duration && duration < MIN_DURATION_S) {
|
||||
return `Video is too short (${duration.toFixed(1)}s). It must be at least ${MIN_DURATION_S} seconds.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
|
||||
export type VideoGenerationModelSpecsResponse = components['schemas']['GenerateVideoModelsSpecsResponse']
|
||||
export type VideoGenerationModelSpecItem = components['schemas']['LTXVideoGenerationModelSpecItem']
|
||||
export type VideoGenerationResolutionSpec = components['schemas']['LTXVideoGenerationResolutionSpec']
|
||||
export type VideoGenerationOfferingCapabilities = NonNullable<
|
||||
VideoGenerationModelSpecItem['spec']['capabilities']
|
||||
>
|
||||
export type VideoGenerationPipeline = components['schemas']['GenerateVideoRequest']['model']
|
||||
export type VideoGenerationResolution = components['schemas']['GenerateVideoRequest']['resolution']
|
||||
export type VideoGenerationDuration = Exclude<
|
||||
components['schemas']['GenerateVideoRequest']['duration'],
|
||||
null
|
||||
>
|
||||
export type VideoGenerationFps = components['schemas']['GenerateVideoRequest']['fps']
|
||||
export type VideoGenerationAspectRatio = components['schemas']['GenerateVideoRequest']['aspectRatio']
|
||||
|
||||
export interface VideoGenerationSettingsShape {
|
||||
model: string
|
||||
duration: number | null
|
||||
videoResolution: string
|
||||
fps: number
|
||||
aspectRatio?: string
|
||||
audio?: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedVideoGenerationOptions {
|
||||
modelOptions: VideoGenerationModelSpecItem[]
|
||||
resolutionOptions: VideoGenerationResolution[]
|
||||
fpsOptions: VideoGenerationFps[]
|
||||
durationOptions: VideoGenerationDuration[]
|
||||
selectedModel: VideoGenerationPipeline | null
|
||||
selectedResolution: VideoGenerationResolution | null
|
||||
selectedFps: VideoGenerationFps | null
|
||||
selectedDuration: VideoGenerationDuration | null
|
||||
autoDurationAvailable: boolean
|
||||
hasCompatibleOptions: boolean
|
||||
}
|
||||
|
||||
type DurationSelectionMode = 'preserve' | 'smallest_valid'
|
||||
|
||||
/** GenSpace picker floor. The API envelope includes 2–5s so gap fill can request shorts. */
|
||||
export const GENSPACE_MIN_SELECTABLE_DURATION_S = 6
|
||||
|
||||
interface ResolveVideoGenerationOptionsParams<T extends VideoGenerationSettingsShape> {
|
||||
settings: T
|
||||
modelSpecs: VideoGenerationModelSpecItem[]
|
||||
hasAudio?: boolean
|
||||
minimumDuration?: number
|
||||
durationSelection?: DurationSelectionMode
|
||||
}
|
||||
|
||||
function getResolutionMap(
|
||||
item: VideoGenerationModelSpecItem,
|
||||
options: { hasAudio: boolean },
|
||||
): Record<string, VideoGenerationResolutionSpec> {
|
||||
const { hasAudio } = options
|
||||
if (!hasAudio) {
|
||||
return item.spec.supported_resolutions_durations
|
||||
}
|
||||
// A model with no a2v spec doesn't support audio-conditioned generation at all —
|
||||
// must not fall back to the plain (non-a2v) matrix, or it looks compatible when it isn't.
|
||||
return item.spec.a2v_supported_resolutions_durations ?? {}
|
||||
}
|
||||
|
||||
function getResolutionEntries(
|
||||
item: VideoGenerationModelSpecItem,
|
||||
options: { hasAudio: boolean },
|
||||
): Array<[VideoGenerationResolution, VideoGenerationResolutionSpec]> {
|
||||
return Object.entries(getResolutionMap(item, options)).map(([resolution, spec]) => [
|
||||
resolution as VideoGenerationResolution,
|
||||
spec,
|
||||
])
|
||||
}
|
||||
|
||||
function getDurationsForFps(
|
||||
resolutionSpec: VideoGenerationResolutionSpec,
|
||||
fps: VideoGenerationFps,
|
||||
): VideoGenerationDuration[] {
|
||||
return (resolutionSpec.fps_to_durations[String(fps)] ?? []) as VideoGenerationDuration[]
|
||||
}
|
||||
|
||||
function filterDurationsByMinimum(
|
||||
durations: VideoGenerationDuration[],
|
||||
minimumDuration: number | undefined,
|
||||
): VideoGenerationDuration[] {
|
||||
if (minimumDuration === undefined) return durations
|
||||
return durations.filter((duration) => duration >= minimumDuration)
|
||||
}
|
||||
|
||||
function getCompatibleFps(
|
||||
resolutionSpec: VideoGenerationResolutionSpec,
|
||||
options: { minimumDuration: number | undefined },
|
||||
): VideoGenerationFps[] {
|
||||
const { minimumDuration } = options
|
||||
return Object.keys(resolutionSpec.fps_to_durations).map((fps) => Number(fps) as VideoGenerationFps).filter((fps) => (
|
||||
filterDurationsByMinimum(getDurationsForFps(resolutionSpec, fps), minimumDuration).length > 0
|
||||
))
|
||||
}
|
||||
|
||||
function getCompatibleResolutionEntries(
|
||||
item: VideoGenerationModelSpecItem,
|
||||
options: { hasAudio: boolean; minimumDuration: number | undefined },
|
||||
): Array<[VideoGenerationResolution, VideoGenerationResolutionSpec]> {
|
||||
return getResolutionEntries(item, { hasAudio: options.hasAudio }).filter(([, resolutionSpec]) => (
|
||||
getCompatibleFps(resolutionSpec, { minimumDuration: options.minimumDuration }).length > 0
|
||||
))
|
||||
}
|
||||
|
||||
function getCompatibleModelOptions(
|
||||
modelSpecs: VideoGenerationModelSpecItem[],
|
||||
options: { hasAudio: boolean; minimumDuration: number | undefined },
|
||||
): VideoGenerationModelSpecItem[] {
|
||||
const { hasAudio, minimumDuration } = options
|
||||
// Always filter by resolution compatibility — hasAudio alone (independent of any
|
||||
// minimumDuration constraint) can exclude a model, e.g. a fast-tier pipeline with no
|
||||
// a2v spec. Skipping this whenever minimumDuration is unset used to let incompatible
|
||||
// (audio-unsupported) models stay selectable and get stuck with no valid resolution.
|
||||
return modelSpecs.filter((item) => (
|
||||
getCompatibleResolutionEntries(item, { hasAudio, minimumDuration }).length > 0
|
||||
))
|
||||
}
|
||||
|
||||
function emptyResolvedOptions(
|
||||
modelOptions: VideoGenerationModelSpecItem[],
|
||||
extras: Partial<ResolvedVideoGenerationOptions> = {},
|
||||
): ResolvedVideoGenerationOptions {
|
||||
return {
|
||||
modelOptions,
|
||||
resolutionOptions: [],
|
||||
fpsOptions: [],
|
||||
durationOptions: [],
|
||||
selectedModel: null,
|
||||
selectedResolution: null,
|
||||
selectedFps: null,
|
||||
selectedDuration: null,
|
||||
autoDurationAvailable: false,
|
||||
hasCompatibleOptions: false,
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
function chooseOption<T>(current: string | number | null, options: T[]): T | null {
|
||||
return options.find((option) => option === current) ?? options[0] ?? null
|
||||
}
|
||||
|
||||
export function getVideoGenerationModelSpecs(
|
||||
specs: VideoGenerationModelSpecsResponse | null | undefined,
|
||||
options: { useApiSpecs: boolean },
|
||||
): VideoGenerationModelSpecItem[] {
|
||||
const { useApiSpecs } = options
|
||||
if (!specs) return []
|
||||
return useApiSpecs ? specs.api_models : specs.local_models
|
||||
}
|
||||
|
||||
export function getLocalOfferingCapabilities(
|
||||
specs: VideoGenerationModelSpecsResponse | null | undefined,
|
||||
): VideoGenerationOfferingCapabilities | null {
|
||||
return specs?.local_models[0]?.spec.capabilities ?? null
|
||||
}
|
||||
|
||||
export function getApiOfferingCapabilities(
|
||||
specs: VideoGenerationModelSpecsResponse | null | undefined,
|
||||
pipeline: string | null | undefined,
|
||||
): VideoGenerationOfferingCapabilities | null {
|
||||
if (!specs || !pipeline) return null
|
||||
return specs.api_models.find((item) => item.pipeline === pipeline)?.spec.capabilities ?? null
|
||||
}
|
||||
|
||||
export function resolveVideoGenerationOptions<T extends VideoGenerationSettingsShape>({
|
||||
settings,
|
||||
modelSpecs,
|
||||
hasAudio = false,
|
||||
minimumDuration,
|
||||
durationSelection = 'preserve',
|
||||
}: ResolveVideoGenerationOptionsParams<T>): ResolvedVideoGenerationOptions {
|
||||
const modelOptions = getCompatibleModelOptions(modelSpecs, { hasAudio, minimumDuration })
|
||||
const selectedModelItem = modelOptions.find((item) => item.pipeline === settings.model) ?? modelOptions[0] ?? null
|
||||
if (!selectedModelItem) {
|
||||
return emptyResolvedOptions(modelOptions)
|
||||
}
|
||||
|
||||
const resolutionEntries = getCompatibleResolutionEntries(selectedModelItem, { hasAudio, minimumDuration })
|
||||
const resolutionOptions = resolutionEntries.map(([resolution]) => resolution)
|
||||
const selectedResolution = chooseOption(settings.videoResolution, resolutionOptions)
|
||||
if (!selectedResolution) {
|
||||
return emptyResolvedOptions(modelOptions, { selectedModel: selectedModelItem.pipeline, resolutionOptions })
|
||||
}
|
||||
|
||||
const selectedResolutionSpec = resolutionEntries.find(([resolution]) => resolution === selectedResolution)?.[1] ?? null
|
||||
if (!selectedResolutionSpec) {
|
||||
return emptyResolvedOptions(modelOptions, {
|
||||
selectedModel: selectedModelItem.pipeline,
|
||||
resolutionOptions,
|
||||
selectedResolution,
|
||||
})
|
||||
}
|
||||
|
||||
const fpsOptions = getCompatibleFps(selectedResolutionSpec, { minimumDuration })
|
||||
const selectedFps = chooseOption(settings.fps, fpsOptions)
|
||||
if (!selectedFps) {
|
||||
return emptyResolvedOptions(modelOptions, {
|
||||
selectedModel: selectedModelItem.pipeline,
|
||||
resolutionOptions,
|
||||
selectedResolution,
|
||||
fpsOptions,
|
||||
})
|
||||
}
|
||||
|
||||
const durationOptions = filterDurationsByMinimum(
|
||||
getDurationsForFps(selectedResolutionSpec, selectedFps),
|
||||
minimumDuration,
|
||||
)
|
||||
const autoDurationAvailable = !hasAudio && Boolean(selectedModelItem.spec.capabilities?.auto_duration)
|
||||
const selectedDuration = durationSelection === 'smallest_valid'
|
||||
? durationOptions[0] ?? null
|
||||
: autoDurationAvailable && settings.duration === null
|
||||
? null
|
||||
: chooseOption(settings.duration, durationOptions)
|
||||
|
||||
return {
|
||||
modelOptions,
|
||||
resolutionOptions,
|
||||
fpsOptions,
|
||||
durationOptions,
|
||||
selectedModel: selectedModelItem.pipeline,
|
||||
selectedResolution,
|
||||
selectedFps,
|
||||
selectedDuration,
|
||||
autoDurationAvailable,
|
||||
hasCompatibleOptions: selectedDuration !== null || autoDurationAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeVideoGenerationSettings<T extends VideoGenerationSettingsShape>(
|
||||
settings: T,
|
||||
modelSpecs: VideoGenerationModelSpecItem[],
|
||||
options: {
|
||||
hasAudio?: boolean
|
||||
minimumDuration?: number
|
||||
durationSelection?: DurationSelectionMode
|
||||
} = {},
|
||||
): T | null {
|
||||
const resolved = resolveVideoGenerationOptions({
|
||||
settings,
|
||||
modelSpecs,
|
||||
hasAudio: options.hasAudio,
|
||||
minimumDuration: options.minimumDuration,
|
||||
durationSelection: options.durationSelection,
|
||||
})
|
||||
if (
|
||||
!resolved.hasCompatibleOptions
|
||||
|| !resolved.selectedModel
|
||||
|| !resolved.selectedResolution
|
||||
|| !resolved.selectedFps
|
||||
|| (resolved.selectedDuration === null && !resolved.autoDurationAvailable)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...settings,
|
||||
model: resolved.selectedModel,
|
||||
videoResolution: resolved.selectedResolution,
|
||||
fps: resolved.selectedFps,
|
||||
duration: resolved.selectedDuration,
|
||||
aspectRatio: (settings.aspectRatio === '9:16' ? '9:16' : '16:9') as VideoGenerationAspectRatio,
|
||||
}
|
||||
}
|
||||
|
||||
export function areVideoGenerationSettingsEquivalent<T extends VideoGenerationSettingsShape>(
|
||||
left: T,
|
||||
right: T,
|
||||
): boolean {
|
||||
return (
|
||||
left.model === right.model
|
||||
&& left.duration === right.duration
|
||||
&& left.videoResolution === right.videoResolution
|
||||
&& left.fps === right.fps
|
||||
&& (left.aspectRatio ?? '16:9') === (right.aspectRatio ?? '16:9')
|
||||
&& (left.audio ?? false) === (right.audio ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback labels for persisted `generationParams.model` / picker pipeline ids.
|
||||
*
|
||||
* Prefer `resolvePipelineDisplayName` (backend spec) at generation time. Local ids like
|
||||
* "fast"/"pro" are shared across LTX versions, so the fallback here stays version-agnostic —
|
||||
* only API ids (`fast-2.5`, …) encode their version in the id itself.
|
||||
*/
|
||||
const PIPELINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
fast: 'LTX Fast',
|
||||
pro: 'LTX Pro',
|
||||
'fast-2.5': 'LTX-2.5 Fast',
|
||||
'pro-2.5': 'LTX-2.5 Pro',
|
||||
}
|
||||
|
||||
/** Returns a display label for a known video pipeline, or null if unknown/absent. */
|
||||
export function formatPipelineDisplayName(model: string | undefined | null): string | null {
|
||||
if (!model) return null
|
||||
return PIPELINE_DISPLAY_NAMES[model] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Version-correct label for `pipeline` taken from the backend specs currently in effect.
|
||||
* Returns null when the pipeline isn't in `modelSpecs`, so callers can fall back.
|
||||
*/
|
||||
export function resolvePipelineDisplayName(
|
||||
modelSpecs: VideoGenerationModelSpecItem[],
|
||||
pipeline: string | undefined | null,
|
||||
): string | null {
|
||||
if (!pipeline) return null
|
||||
return modelSpecs.find((item) => item.pipeline === pipeline)?.spec.display_name ?? null
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Resolution tier options for local retake/extend. Offers the source resolution plus
|
||||
// standard lower tiers (named by short edge: 1080p / 720p / 540p). Grid sizes such as
|
||||
// 576/704/1088 map to the nearest named tier. The backend snaps the chosen size to a
|
||||
// valid (÷32, not-upscaled) resolution. Local only — the cloud preserves source resolution.
|
||||
|
||||
export interface ResolutionOption {
|
||||
key: string
|
||||
label: string
|
||||
// null = "Original" (backend uses the source resolution, still ÷32-corrected).
|
||||
width: number | null
|
||||
height: number | null
|
||||
}
|
||||
|
||||
const STANDARD_TIERS = [1080, 720, 540]
|
||||
const NAMED_TIERS = [2160, 1440, 1080, 720, 540] as const
|
||||
|
||||
/** Map a pixel short-edge (incl. /64 grid sizes like 576, 704, 1088) to the picker tier name. */
|
||||
export function namedResolutionTier(shortEdge: number): (typeof NAMED_TIERS)[number] {
|
||||
return NAMED_TIERS.reduce((best, tier) =>
|
||||
Math.abs(tier - shortEdge) < Math.abs(best - shortEdge) ? tier : best,
|
||||
)
|
||||
}
|
||||
|
||||
export function namedResolutionDisplayName(tier: number): string {
|
||||
return tier >= 2160 ? '4K' : `${tier}p`
|
||||
}
|
||||
|
||||
/** Display label for generation resolution ids (`2160p` → `4K`). */
|
||||
export function videoGenerationResolutionLabel(resolution: string): string {
|
||||
return resolution === '2160p' ? '4K' : resolution
|
||||
}
|
||||
|
||||
export function resolutionOptions(width: number, height: number): ResolutionOption[] {
|
||||
if (!width || !height) return []
|
||||
const shortEdge = Math.min(width, height)
|
||||
const longEdge = Math.max(width, height)
|
||||
const portrait = height > width
|
||||
|
||||
const originalTier = namedResolutionTier(shortEdge)
|
||||
|
||||
const options: ResolutionOption[] = [
|
||||
{ key: 'original', label: `${namedResolutionDisplayName(originalTier)} (Original)`, width: null, height: null },
|
||||
]
|
||||
for (const tier of STANDARD_TIERS) {
|
||||
// Only smaller tiers, and drop the one that already maps to Original.
|
||||
if (tier >= shortEdge || tier === originalTier) continue
|
||||
const long = Math.round((longEdge * tier) / shortEdge)
|
||||
options.push({
|
||||
key: String(tier),
|
||||
label: `${tier}p`,
|
||||
width: portrait ? tier : long,
|
||||
height: portrait ? long : tier,
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ElectronAPI, BackendHealthStatus } from '../../shared/electron-api-schema'
|
||||
|
||||
const pendingFiles = new Map<string, File>()
|
||||
const uploads = new Map<string, Promise<string>>()
|
||||
export function mediaUrl(path: string): string {
|
||||
return path.startsWith('blob:') || path.startsWith('data:') ? path : `/web/media?path=${encodeURIComponent(path)}`
|
||||
}
|
||||
export async function webRequest<T>(path: string, value?: unknown): Promise<T> {
|
||||
const response = await fetch(path, value === undefined ? undefined : {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Deskweb-Request': '1' }, body: JSON.stringify(value),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`)
|
||||
return data as T
|
||||
}
|
||||
function status(message: string) { window.dispatchEvent(new CustomEvent('web-status', { detail: message })) }
|
||||
async function upload(file: File): Promise<string> {
|
||||
status(`Uploading ${file.name}…`)
|
||||
try {
|
||||
const data = await new Promise<{ path: string }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/web/upload?name=${encodeURIComponent(file.name)}`)
|
||||
xhr.setRequestHeader('X-Deskweb-Request', '1')
|
||||
xhr.upload.onprogress = event => { if (event.lengthComputable) status(`Uploading ${file.name}: ${Math.round(event.loaded / event.total * 100)}%`) }
|
||||
xhr.onload = () => { try { const result = JSON.parse(xhr.responseText); if (xhr.status < 200 || xhr.status >= 300) reject(new Error(result.error)); else resolve(result) } catch { reject(new Error('Invalid upload response')) } }
|
||||
xhr.onerror = () => reject(new Error('Upload connection failed'))
|
||||
xhr.send(file)
|
||||
})
|
||||
status('Upload complete'); return data.path
|
||||
} catch (e) { status(`Upload failed: ${e instanceof Error ? e.message : String(e)}`); throw e }
|
||||
}
|
||||
async function resolveInput(input: string): Promise<string> {
|
||||
const file = pendingFiles.get(input)
|
||||
if (!file) return input
|
||||
if (!uploads.has(input)) uploads.set(input, upload(file).catch(e => { uploads.delete(input); throw e }))
|
||||
return uploads.get(input)!
|
||||
}
|
||||
function download(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||
link.href = url; link.download = name.split('/').pop() || 'download'; link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
}
|
||||
const open = (url: string) => { const parsed = new URL(url); if (!['https:', 'http:'].includes(parsed.protocol)) return false; window.open(url, '_blank', 'noopener,noreferrer'); return true }
|
||||
const fileOperation = <T>(action: string, input: Record<string, unknown>) => webRequest<T>('/web/files', { action, ...input })
|
||||
type Dimensions = { success: true; width: number; height: number }
|
||||
type Thumbnails = { success: true; bigThumbnailPath: string; smallThumbnailPath: string }
|
||||
async function health(): Promise<BackendHealthStatus> {
|
||||
try {
|
||||
const response = await fetch('/ltx/health', { signal: AbortSignal.timeout(10000) })
|
||||
if (!response.ok) return { status: 'dead' }
|
||||
// Deck's own health endpoint is not proof that the LTX API is active.
|
||||
const specs = await fetch('/ltx/api/generate/models-specs', { signal: AbortSignal.timeout(10000) })
|
||||
return { status: specs.ok ? 'alive' : 'dead' }
|
||||
} catch { return { status: 'dead' } }
|
||||
}
|
||||
const bridge: Partial<ElectronAPI> = {
|
||||
platform: 'web',
|
||||
getBackend: async () => ({ url: `${location.origin}/ltx`, token: '' }),
|
||||
getBackendHealthStatus: health,
|
||||
onBackendHealthStatus: callback => {
|
||||
let active = true, timer: ReturnType<typeof setTimeout>
|
||||
const poll = async () => { const result = await health(); if (active) { callback(result); timer = setTimeout(poll, 15000) } }
|
||||
void poll(); return () => { active = false; clearTimeout(timer) }
|
||||
},
|
||||
getPathForFile: file => { const url = URL.createObjectURL(file); pendingFiles.set(url, file); return url },
|
||||
showOpenFileDialog: options => new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input'); input.type = 'file'; input.multiple = Boolean(options.properties?.includes('multiSelections'))
|
||||
input.accept = options.filters?.flatMap(filter => filter.extensions.map(ext => `.${ext}`)).join(',') || ''
|
||||
input.oncancel = () => resolve(null)
|
||||
input.onchange = () => { Promise.all(Array.from(input.files || []).map(upload)).then(resolve, reject) }
|
||||
input.click()
|
||||
}),
|
||||
prepareGenerationInput: async ({ path }) => ({ success: true, path: await resolveInput(path) }),
|
||||
addGenericAssetToProject: async ({ srcPath }) => fileOperation('copy', { path: await resolveInput(srcPath) }),
|
||||
addVisualAssetToProject: async ({ srcPath }) => {
|
||||
const copied = await fileOperation<{ path: string }>('copy', { path: await resolveInput(srcPath) })
|
||||
const dimensions = await fileOperation<Dimensions>('metadata', copied)
|
||||
const thumbs = await fileOperation<Thumbnails>('thumbnail', copied)
|
||||
return { ...dimensions, ...thumbs, success: true, path: copied.path }
|
||||
},
|
||||
makeDimensionsForProjectAsset: async ({ path }) => fileOperation('metadata', { path: await resolveInput(path) }),
|
||||
makeThumbnailsForProjectAsset: async ({ path }) => fileOperation('thumbnail', { path: await resolveInput(path) }),
|
||||
extractVideoFrame: async ({ videoPath, seekTime }) => fileOperation('frame', { path: await resolveInput(videoPath), seekTime }),
|
||||
checkFilesExist: async ({ filePaths }) => fileOperation('exists', { paths: filePaths }),
|
||||
readLocalFile: async ({ filePath }) => {
|
||||
const response = await fetch(mediaUrl(filePath)); if (!response.ok) throw Error('Cannot read media')
|
||||
const buffer = new Uint8Array(await response.arrayBuffer()); let binary = ''
|
||||
for (let offset = 0; offset < buffer.length; offset += 8192) binary += String.fromCharCode(...buffer.subarray(offset, offset + 8192))
|
||||
return { data: btoa(binary), mimeType: response.headers.get('content-type') || 'application/octet-stream' }
|
||||
},
|
||||
showSaveDialog: async options => options.defaultPath || 'download',
|
||||
saveFile: async ({ filePath, data, encoding }) => { const content = encoding === 'base64' ? Uint8Array.from(atob(data), c => c.charCodeAt(0)) : data; download(new Blob([content]), filePath); return { success: true, path: filePath } },
|
||||
saveBinaryFile: async ({ filePath, data }) => { download(new Blob([data]), filePath); return { success: true, path: filePath } },
|
||||
showItemInFolder: async ({ filePath }) => {
|
||||
const link = document.createElement('a'); link.href = `${mediaUrl(filePath)}&download=1`; link.download = filePath.split('/').pop() || 'clip'; link.click()
|
||||
},
|
||||
openParentFolderOfFile: async () => { status('The file was downloaded to your browser download folder.') },
|
||||
openExternalUrl: async ({ url }) => open(url),
|
||||
openHuggingFaceRepo: async ({ repoId }) => open(`https://huggingface.co/${repoId}`),
|
||||
openLtxApiKeyPage: async () => open('https://console.ltx.video'),
|
||||
openLtxBillingPage: async () => open('https://console.ltx.video'),
|
||||
openFalApiKeyPage: async () => open('https://fal.ai/dashboard'),
|
||||
getAppInfo: async () => ({ version: '0.1.0', isPackaged: true, modelsPath: 'Managed by LTX backend', userDataPath: 'Browser project storage' }),
|
||||
getProjectAssetsPath: async () => 'Shared LTX input directory',
|
||||
getModelsPath: async () => 'Managed by LTX backend',
|
||||
getDownloadsPath: async () => '',
|
||||
getResourcePath: async () => null,
|
||||
getNoticesText: async () => (await fetch('/NOTICES.md')).text(),
|
||||
fetchLicenseText: async () => (await fetch('/LICENSE.txt')).text(),
|
||||
notifyGenerationActive: async () => {}, // No process watchdog; Deck owns lifecycle.
|
||||
writeLog: async () => {}, // Never persist prompts or media URLs in the web service.
|
||||
getAnalyticsState: async () => ({ analyticsEnabled: false, installationId: '' }),
|
||||
setAnalyticsEnabled: async () => {},
|
||||
sendAnalyticsEvent: async () => {},
|
||||
getUpdateState: async () => ({ status: 'not-available', currentVersion: '0.1.0' }),
|
||||
onUpdateEvent: () => () => {},
|
||||
getAutoCheckUpdates: async () => ({ enabled: false }),
|
||||
exportNative: async () => ({ success: false, error: 'Timeline rendering is not available in this first web version. Download individual clips or export the timeline as XML.' }),
|
||||
}
|
||||
// Preserve the inherited typed UI boundary while porting. Unsupported desktop operations
|
||||
// fail explicitly, never silently simulate installation, export, or process control.
|
||||
window.electronAPI = new Proxy(bridge, {
|
||||
get(target, property) {
|
||||
if (property in target) return target[property as keyof ElectronAPI]
|
||||
return async () => { const message = `Desktop function “${String(property)}” is not available in LTX DeskWEB.`; status(message); throw new Error(message) }
|
||||
},
|
||||
}) as ElectronAPI
|
||||
Reference in new issue
Block a user