Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386 committed 2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 files changed
+63054

No files matched your search

+446
View File
@@ -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
+56
View File
@@ -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
}
+24
View File
@@ -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',
},
)
})
})
+33
View File
@@ -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 } : {}),
}
}
+72
View File
@@ -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',
})
})
})
+25
View File
@@ -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
)
}
+2
View File
@@ -0,0 +1,2 @@
import { mediaUrl } from './web-platform'
export function pathToFileUrl(filePath: string): string { return mediaUrl(filePath) }
+66
View File
@@ -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)
})
})
+33
View File
@@ -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 }
}
+17
View File
@@ -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')
})
})
+8
View File
@@ -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]}`
}
+43
View File
@@ -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
}
+21
View File
@@ -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,
},
}
}
+6
View File
@@ -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
}
+91
View File
@@ -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,
}
+107
View File
@@ -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)
}
+112
View File
@@ -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
}
+33
View File
@@ -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,
)
})
})
+7
View File
@@ -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
}
+61
View File
@@ -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)
})
})
+39
View File
@@ -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)
})
})
+81
View File
@@ -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'
}
+490
View File
@@ -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
}
+53
View File
@@ -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)
})
})
+52
View File
@@ -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)
}
+62
View File
@@ -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'],
)
})
})
+36
View File
@@ -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)
}
+53
View File
@@ -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%')
})
})
+45
View File
@@ -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)}%`
}
+381
View File
@@ -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)
})
})
+243
View File
@@ -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)
}
+13
View File
@@ -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),
}
+238
View File
@@ -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,
}
})
}
+289
View File
@@ -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,
)
})
})
+136
View File
@@ -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,
}
}
+61
View File
@@ -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,
}
}
+68
View File
@@ -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))
}
+11
View File
@@ -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')
})
+8
View File
@@ -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.' }
}
+159
View File
@@ -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
+29
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+32
View File
@@ -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
}
+56
View File
@@ -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
}
+127
View File
@@ -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