From 7f03252280853f7f2335d79be9d629e9cf6b164d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 17:20:44 +0200 Subject: [PATCH 1/3] refactor: schema region consolidation + playback cap + deep-agent fold --- .../agent-provider-capabilities.test.ts | 6 +- .../deep-agent/agent-provider-capabilities.ts | 257 ----------------- electron/ai-edition/deep-agent/chat-model.ts | 272 +++++++++++++++++- .../ai-edition/v4/FloatingInspector.tsx | 4 +- .../ai-edition/v4/SpeedControl.test.tsx | 8 +- .../video-editor/customPlaybackSpeed.test.ts | 17 +- src/components/video-editor/types.ts | 6 +- src/lib/ai-edition/schema/index.ts | 79 ++--- 8 files changed, 332 insertions(+), 317 deletions(-) delete mode 100644 electron/ai-edition/deep-agent/agent-provider-capabilities.ts diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts index 0b6581ed..d9a6c306 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts @@ -1,13 +1,13 @@ // Covers the reasoning-effort matrix that survived the llm-call.ts deletion. -// The deleted top-level agent-provider-capabilities.ts had the only tests for -// this logic; these keep the per-provider wiring pinned. +// The capability helpers now live in chat-model.ts (folded from +// agent-provider-capabilities.ts); these keep the per-provider wiring pinned. import { describe, expect, it } from "vitest"; import { buildLangChainReasoningOptions, getReasoningCapability, normalizeReasoningEffortForCapability, -} from "./agent-provider-capabilities"; +} from "./chat-model"; describe("getReasoningCapability", () => { it("turns reasoning off for non-reasoning OpenAI models", () => { diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts deleted file mode 100644 index fde62ca7..00000000 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts +++ /dev/null @@ -1,257 +0,0 @@ -// ponytail: port of axcut's agent-provider-capabilities.ts. Drives the -// per-provider reasoning-effort wiring (OpenAI uses `reasoning.effort`, -// Anthropic uses `thinking` blocks, OpenRouter uses `modelKwargs.reasoning`, -// Google uses `thinkingConfig`). Lives behind createOpenScreenChatModel. - -import { getProviderDefinition, type ProviderDefinition } from "../provider-registry"; - -export type AgentReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; - -export const AGENT_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -]; - -export interface ReasoningCapability { - supported: boolean; - efforts: readonly AgentReasoningEffort[]; - defaultEffort?: AgentReasoningEffort; - strategy?: - | "custom-openai-account" - | "openai-responses" - | "anthropic-thinking" - | "minimax-thinking" - | "openrouter-reasoning" - | "google-thinking"; -} - -export interface LangChainReasoningOptions { - reasoning?: { effort: "low" | "medium" | "high" }; - thinking?: Record; - outputConfig?: Record; - thinkingConfig?: Record; - modelKwargs?: Record; - useResponsesApi?: boolean; -} - -const OPENAI_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; -const ANTHROPIC_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "low", - "medium", - "high", - "xhigh", -]; -const OPENROUTER_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "low", - "medium", - "high", -]; -const GOOGLE_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; - -// Every branch here compares against canonical registry ids — -// createOpenScreenChatModel normalizes the provider before calling in. -export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { - const def: ProviderDefinition | undefined = getProviderDefinition(provider); - const normalizedModel = normalizeModelName(model); - - if ( - (provider === "openai" || provider === "openai-compatible") && - isOpenAIReasoningModel(normalizedModel) - ) { - return { - supported: true, - efforts: OPENAI_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "openai-responses", - }; - } - if (def?.id === "anthropic" && isAnthropicReasoningModel(normalizedModel)) { - return { - supported: true, - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "anthropic-thinking", - }; - } - if (provider === "minimax" || provider === "minimax-token-plan") { - // MiniMax's thinking block is binary — `{type: "adaptive"}` (on) or - // `{type: "disabled"}` (off, ignored on M2.x which is always-on) — no - // budget_tokens tiers like native Anthropic. Any non-"none" effort - // just turns it on; see buildLangChainReasoningOptions below. - return { - supported: true, - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "minimax-thinking", - }; - } - if (provider === "openrouter" && isOpenRouterReasoningModel(normalizedModel)) { - return { - supported: true, - efforts: OPENROUTER_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "openrouter-reasoning", - }; - } - if (provider === "google" && isGeminiThinkingModel(normalizedModel)) { - return { - supported: true, - efforts: GOOGLE_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "google-thinking", - }; - } - return { supported: false, efforts: ["none"] }; -} - -export function normalizeReasoningEffortForCapability( - effort: AgentReasoningEffort | undefined, - capability: ReasoningCapability, -): AgentReasoningEffort | undefined { - if (!capability.supported) return undefined; - if (!effort) return capability.defaultEffort; - if (capability.efforts.includes(effort)) return effort; - if (effort === "minimal" && capability.efforts.includes("low")) return "low"; - if (effort === "xhigh" && capability.efforts.includes("high")) return "high"; - return capability.defaultEffort; -} - -export function buildLangChainReasoningOptions( - provider: string, - model: string | undefined, - effort: AgentReasoningEffort | undefined, -): LangChainReasoningOptions { - const capability = getReasoningCapability(provider, model); - const normalizedEffort = normalizeReasoningEffortForCapability(effort, capability); - if (!capability.supported || !normalizedEffort || normalizedEffort === "none") { - return {}; - } - - switch (capability.strategy) { - case "openai-responses": - return { - reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, - useResponsesApi: true, - }; - case "anthropic-thinking": - return buildAnthropicReasoningOptions(model, normalizedEffort); - case "minimax-thinking": - // No "none" case needed — normalizedEffort === "none" already - // short-circuits to {} above, which omits `thinking` (= off). - return { thinking: { type: "adaptive" } }; - case "openrouter-reasoning": - return { - modelKwargs: { - reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, - include_reasoning: true, - }, - }; - case "google-thinking": - return { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: toGoogleThinkingLevel(normalizedEffort), - thinkingBudget: toGoogleThinkingBudget(normalizedEffort), - }, - }; - default: - return {}; - } -} - -export function shouldDisableModelStreamingForToolCalling( - provider: string, - model?: string, -): boolean { - return provider === "google" && normalizeModelName(model).startsWith("gemini-3"); -} - -function buildAnthropicReasoningOptions( - model: string | undefined, - effort: AgentReasoningEffort, -): LangChainReasoningOptions { - if (isAnthropicAdaptiveThinkingModel(model)) { - return { - thinking: { type: "adaptive", display: "summarized" }, - outputConfig: { effort: toAnthropicEffort(effort) }, - }; - } - return { - thinking: { - type: "enabled", - budget_tokens: toAnthropicBudgetTokens(effort), - display: "summarized", - }, - }; -} - -function isOpenAIReasoningModel(model: string): boolean { - return /^(o\d|o\d-|o\d\.|gpt-5|gpt-5-|gpt-5\.)/.test(model); -} - -function isAnthropicReasoningModel(model: string): boolean { - return /^claude-(opus|sonnet|haiku)-4/.test(model); -} - -function isAnthropicAdaptiveThinkingModel(model: string | undefined): boolean { - if (!model) return false; - return /^claude-(opus|sonnet)-4-[67]/.test(model); -} - -function isGeminiThinkingModel(model: string): boolean { - return model.startsWith("gemini-2.5") || model.startsWith("gemini-3"); -} - -function isOpenRouterReasoningModel(model: string): boolean { - // OpenRouter slugs are `vendor/model`, and both vendor matchers are - // anchored at the start — so the prefix has to come off first or - // `openai/gpt-5` never matches. - if (isOpenAIReasoningModel(stripVendorPrefix(model, "openai/"))) return true; - if (isAnthropicReasoningModel(stripVendorPrefix(model, "anthropic/"))) return true; - return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model); -} - -function stripVendorPrefix(model: string, prefix: string): string { - return model.startsWith(prefix) ? model.slice(prefix.length) : model; -} - -function toOpenAIReasoningEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" { - if (effort === "high" || effort === "xhigh") return "high"; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" | "xhigh" { - if (effort === "high" || effort === "xhigh") return effort; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicBudgetTokens(effort: AgentReasoningEffort): number { - if (effort === "xhigh") return 16_000; - if (effort === "high") return 10_000; - if (effort === "medium") return 4_000; - return 1_024; -} - -function toGoogleThinkingLevel(effort: AgentReasoningEffort): "LOW" | "MEDIUM" | "HIGH" { - if (effort === "high" || effort === "xhigh") return "HIGH"; - if (effort === "medium") return "MEDIUM"; - return "LOW"; -} - -function toGoogleThinkingBudget(effort: AgentReasoningEffort): number { - if (effort === "high" || effort === "xhigh") return 8_192; - if (effort === "medium") return 4_096; - return 1_024; -} - -function normalizeModelName(model?: string): string { - return model?.trim().toLowerCase() || ""; -} diff --git a/electron/ai-edition/deep-agent/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index 80e1be75..939e3669 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -5,16 +5,277 @@ // // The openai-oauth (Codex) and copilot-proxy branches were removed in 1.8.0 // along with their providers — see the note in provider-registry.ts. +// +// ponytail: the per-provider reasoning-effort capability table (was +// ./agent-provider-capabilities.ts) is only consumed by createOpenScreenChatModel, +// so it lives here as a top section. The deep-agent runtime wrapper +// (./service.ts) stays separate because it owns a different concern — the +// LangGraph thread + tool graph, not the chat-model factory. import { ChatAnthropic } from "@langchain/anthropic"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatOpenAI } from "@langchain/openai"; -import { normalizeProviderId } from "../provider-registry"; import { - buildLangChainReasoningOptions, - shouldDisableModelStreamingForToolCalling, -} from "./agent-provider-capabilities"; + getProviderDefinition, + normalizeProviderId, + type ProviderDefinition, +} from "../provider-registry"; + +// --- per-provider reasoning-effort capability table ----------------------- + +export type AgentReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export const AGENT_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +export interface ReasoningCapability { + supported: boolean; + efforts: readonly AgentReasoningEffort[]; + defaultEffort?: AgentReasoningEffort; + strategy?: + | "custom-openai-account" + | "openai-responses" + | "anthropic-thinking" + | "minimax-thinking" + | "openrouter-reasoning" + | "google-thinking"; +} + +export interface LangChainReasoningOptions { + reasoning?: { effort: "low" | "medium" | "high" }; + thinking?: Record; + outputConfig?: Record; + thinkingConfig?: Record; + modelKwargs?: Record; + useResponsesApi?: boolean; +} + +const OPENAI_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; +const ANTHROPIC_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "low", + "medium", + "high", + "xhigh", +]; +const OPENROUTER_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "low", + "medium", + "high", +]; +const GOOGLE_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; + +// Every branch here compares against canonical registry ids — +// createOpenScreenChatModel normalizes the provider before calling in. +export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { + const def: ProviderDefinition | undefined = getProviderDefinition(provider); + const normalizedModel = normalizeModelName(model); + + if ( + (provider === "openai" || provider === "openai-compatible") && + isOpenAIReasoningModel(normalizedModel) + ) { + return { + supported: true, + efforts: OPENAI_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "openai-responses", + }; + } + if (def?.id === "anthropic" && isAnthropicReasoningModel(normalizedModel)) { + return { + supported: true, + efforts: ANTHROPIC_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "anthropic-thinking", + }; + } + if (provider === "minimax" || provider === "minimax-token-plan") { + // MiniMax's thinking block is binary — `{type: "adaptive"}` (on) or + // `{type: "disabled"}` (off, ignored on M2.x which is always-on) — no + // budget_tokens tiers like native Anthropic. Any non-"none" effort + // just turns it on; see buildLangChainReasoningOptions below. + return { + supported: true, + efforts: ANTHROPIC_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "minimax-thinking", + }; + } + if (provider === "openrouter" && isOpenRouterReasoningModel(normalizedModel)) { + return { + supported: true, + efforts: OPENROUTER_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "openrouter-reasoning", + }; + } + if (provider === "google" && isGeminiThinkingModel(normalizedModel)) { + return { + supported: true, + efforts: GOOGLE_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "google-thinking", + }; + } + return { supported: false, efforts: ["none"] }; +} + +export function normalizeReasoningEffortForCapability( + effort: AgentReasoningEffort | undefined, + capability: ReasoningCapability, +): AgentReasoningEffort | undefined { + if (!capability.supported) return undefined; + if (!effort) return capability.defaultEffort; + if (capability.efforts.includes(effort)) return effort; + if (effort === "minimal" && capability.efforts.includes("low")) return "low"; + if (effort === "xhigh" && capability.efforts.includes("high")) return "high"; + return capability.defaultEffort; +} + +export function buildLangChainReasoningOptions( + provider: string, + model: string | undefined, + effort: AgentReasoningEffort | undefined, +): LangChainReasoningOptions { + const capability = getReasoningCapability(provider, model); + const normalizedEffort = normalizeReasoningEffortForCapability(effort, capability); + if (!capability.supported || !normalizedEffort || normalizedEffort === "none") { + return {}; + } + + switch (capability.strategy) { + case "openai-responses": + return { + reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, + useResponsesApi: true, + }; + case "anthropic-thinking": + return buildAnthropicReasoningOptions(model, normalizedEffort); + case "minimax-thinking": + // No "none" case needed — normalizedEffort === "none" already + // short-circuits to {} above, which omits `thinking` (= off). + return { thinking: { type: "adaptive" } }; + case "openrouter-reasoning": + return { + modelKwargs: { + reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, + include_reasoning: true, + }, + }; + case "google-thinking": + return { + thinkingConfig: { + includeThoughts: true, + thinkingLevel: toGoogleThinkingLevel(normalizedEffort), + thinkingBudget: toGoogleThinkingBudget(normalizedEffort), + }, + }; + default: + return {}; + } +} + +export function shouldDisableModelStreamingForToolCalling( + provider: string, + model?: string, +): boolean { + return provider === "google" && normalizeModelName(model).startsWith("gemini-3"); +} + +function buildAnthropicReasoningOptions( + model: string | undefined, + effort: AgentReasoningEffort, +): LangChainReasoningOptions { + if (isAnthropicAdaptiveThinkingModel(model)) { + return { + thinking: { type: "adaptive", display: "summarized" }, + outputConfig: { effort: toAnthropicEffort(effort) }, + }; + } + return { + thinking: { + type: "enabled", + budget_tokens: toAnthropicBudgetTokens(effort), + display: "summarized", + }, + }; +} + +function isOpenAIReasoningModel(model: string): boolean { + return /^(o\d|o\d-|o\d\.|gpt-5|gpt-5-|gpt-5\.)/.test(model); +} + +function isAnthropicReasoningModel(model: string): boolean { + return /^claude-(opus|sonnet|haiku)-4/.test(model); +} + +function isAnthropicAdaptiveThinkingModel(model: string | undefined): boolean { + if (!model) return false; + return /^claude-(opus|sonnet)-4-[67]/.test(model); +} + +function isGeminiThinkingModel(model: string): boolean { + return model.startsWith("gemini-2.5") || model.startsWith("gemini-3"); +} + +function isOpenRouterReasoningModel(model: string): boolean { + // OpenRouter slugs are `vendor/model`, and both vendor matchers are + // anchored at the start — so the prefix has to come off first or + // `openai/gpt-5` never matches. + if (isOpenAIReasoningModel(stripVendorPrefix(model, "openai/"))) return true; + if (isAnthropicReasoningModel(stripVendorPrefix(model, "anthropic/"))) return true; + return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model); +} + +function stripVendorPrefix(model: string, prefix: string): string { + return model.startsWith(prefix) ? model.slice(prefix.length) : model; +} + +function toOpenAIReasoningEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" { + if (effort === "high" || effort === "xhigh") return "high"; + if (effort === "medium") return "medium"; + return "low"; +} + +function toAnthropicEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" | "xhigh" { + if (effort === "high" || effort === "xhigh") return effort; + if (effort === "medium") return "medium"; + return "low"; +} + +function toAnthropicBudgetTokens(effort: AgentReasoningEffort): number { + if (effort === "xhigh") return 16_000; + if (effort === "high") return 10_000; + if (effort === "medium") return 4_000; + return 1_024; +} + +function toGoogleThinkingLevel(effort: AgentReasoningEffort): "LOW" | "MEDIUM" | "HIGH" { + if (effort === "high" || effort === "xhigh") return "HIGH"; + if (effort === "medium") return "MEDIUM"; + return "LOW"; +} + +function toGoogleThinkingBudget(effort: AgentReasoningEffort): number { + if (effort === "high" || effort === "xhigh") return 8_192; + if (effort === "medium") return 4_096; + return 1_024; +} + +function normalizeModelName(model?: string): string { + return model?.trim().toLowerCase() || ""; +} + +// --- chat-model factory ---------------------------------------------------- export interface OpenScreenChatModelConfig { provider: string; @@ -59,8 +320,7 @@ export async function createOpenScreenChatModel( ): Promise { // Canonicalise once, here: stored configs can still carry historical // aliases (`claude`, `gemini`, `anthropic-proxy`), and every provider - // comparison below — and in agent-provider-capabilities — is an exact - // match against a registry id. + // comparison below is an exact match against a registry id. const config: OpenScreenChatModelConfig = { ...input, provider: normalizeProviderId(input.provider) ?? input.provider, diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index f7e9cb17..41f03e51 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -367,8 +367,8 @@ const SPEED_PRESETS = [1, ...SPEED_OPTIONS.map((option) => option.speed)].sort(( * (`settings.speed.customPlaybackSpeed` / `maxSpeedError` / `previewFrameSteppingHint`, shipped * in all 13 locales) but no longer any control for: the V4 shell replaced the panel that hosted * it with a preset-only `` capped at 3×, while the underlying capability goes to - * `MAX_PLAYBACK_SPEED` (16×, the native HTMLMediaElement ceiling). Only the control was - * missing, so this rewires it rather than adding anything new. + * `MAX_PLAYBACK_SPEED` (100×). Only the control was missing, so this rewires it rather than + * adding anything new. */ export function SpeedControl({ region, diff --git a/src/components/ai-edition/v4/SpeedControl.test.tsx b/src/components/ai-edition/v4/SpeedControl.test.tsx index 05d68be4..c510227d 100644 --- a/src/components/ai-edition/v4/SpeedControl.test.tsx +++ b/src/components/ai-edition/v4/SpeedControl.test.tsx @@ -32,11 +32,11 @@ describe("SpeedControl", () => { it("commits a free-typed speed above the preset ceiling", () => { // The regression this control exists for: the V4 shell only offered presets up to 3×, - // while the underlying capability reaches 16× (the native HTMLMediaElement ceiling). + // while the underlying capability reaches 100×. const { updateSpeedValue, field } = renderControl(1); - fireEvent.change(field, { target: { value: "10" } }); + fireEvent.change(field, { target: { value: "25" } }); fireEvent.blur(field); - expect(updateSpeedValue).toHaveBeenCalledWith("sp1", 10); + expect(updateSpeedValue).toHaveBeenCalledWith("sp1", 25); }); it("commits on Enter as well as on blur, and only once", () => { @@ -53,7 +53,7 @@ describe("SpeedControl", () => { fireEvent.change(field, { target: { value: "500" } }); fireEvent.blur(field); expect(updateSpeedValue).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith("speed.maxSpeedError:16"); + expect(toastError).toHaveBeenCalledWith("speed.maxSpeedError:100"); }); it("ignores an unparseable draft without touching the region", () => { diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts index b38323fc..aaffb2b5 100644 --- a/src/components/video-editor/customPlaybackSpeed.test.ts +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -42,25 +42,26 @@ describe("parseCustomPlaybackSpeedInput", () => { }); it("accepts the maximum editor speed", () => { - expect(parseCustomPlaybackSpeedInput("16")).toEqual({ + expect(parseCustomPlaybackSpeedInput("100")).toEqual({ status: "valid", - draft: "16", - speed: 16, + draft: "100", + speed: 100, }); }); - it("rejects speeds that exceed the native preview rate", () => { - // 16.1× exceeds Chromium's playbackRate ceiling, so the editor rejects it. + it("accepts high speeds that exceed the native preview rate", () => { + // 16.1× was rejected under the old 16× cap; it must now be valid. expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({ - status: "too-fast", + status: "valid", draft: "16.1", + speed: 16.1, }); }); it("rejects speeds above the editor maximum", () => { - expect(parseCustomPlaybackSpeedInput("50")).toEqual({ + expect(parseCustomPlaybackSpeedInput("100.1")).toEqual({ status: "too-fast", - draft: "50", + draft: "100.1", }); }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index d0745cef..62740284 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -34,8 +34,10 @@ import { DEFAULT_ZOOM_MOTION_BLUR, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, + MAX_PLAYBACK_SPEED, MIN_BLUR_BLOCK_SIZE, MIN_BLUR_INTENSITY, + MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, type WebcamLayoutPreset, @@ -318,13 +320,12 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - // Clamp an out-of-range speed rather than discarding it: a saved 25× - // should become the cap, not silently reset to the 1.5× default. - // Lowering MAX_PLAYBACK_SPEED widened the reset window, so the - // range test now only guards against non-numeric values. - const speed = isFiniteNumber(region.speed) - ? clampPlaybackSpeed(region.speed) - : DEFAULT_PLAYBACK_SPEED; + const speed = + isFiniteNumber(region.speed) && + region.speed >= MIN_PLAYBACK_SPEED && + region.speed <= MAX_PLAYBACK_SPEED + ? clampPlaybackSpeed(region.speed) + : DEFAULT_PLAYBACK_SPEED; return { id: region.id, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 38c2fb2b..725cd970 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -390,20 +390,10 @@ export const DEFAULT_CROP_REGION: CropRegion = { export type PlaybackSpeed = number; export const MIN_PLAYBACK_SPEED = 0.1; +export const MAX_PLAYBACK_SPEED = 100; // Chromium hard-caps HTMLMediaElement.playbackRate at 16 (setting more throws // NotSupportedError). At or below this, preview plays natively; above it, preview // frame-steps by seeking and audio export uses an offline pitch-preserved stretch. -// ponytail: the editor cap and the native preview cap are the same number — -// everything above this is the export path's stretch, and the editor's input -// parser shouldn't accept speeds the preview cannot render. A 50× entry is -// REJECTED (the field reverts to its previous value), not clamped — see -// `parseCustomPlaybackSpeedInput`, which returns `too-fast` above this bound. -// -// NOTE: this bound is enforced on the editor input only. The LLM agent tools -// (electron/ai-edition/agent-tools.ts) still accept any positive speed, so an -// agent-authored region can exceed it: preview clamps to MAX_NATIVE_PLAYBACK_RATE -// while export renders the true speed. -export const MAX_PLAYBACK_SPEED = 16; export const MAX_NATIVE_PLAYBACK_RATE = 16; export function clampPlaybackSpeed(speed: number): PlaybackSpeed {