+ We mapped a {measured} subject window, then stopped before starting any
+ video render calls. This workspace currently supports up to {limitSeconds.toFixed(0)} seconds.
+
+
{plan.provider}
-
{plan.estimate.expected_generation_calls} call
+
{plan.estimate.expected_generation_calls} {plan.estimate.expected_generation_calls === 1 ? "call" : "calls"}
{plan.estimate.expected_generated_seconds.toFixed(1)} generated seconds
{plan.estimate.analysis_duration_ms.toFixed(0)}ms planning
{plan.estimate.frames_inspected} frames inspected
diff --git a/apps/web/src/components/editor-port/inspector.css b/apps/web/src/components/editor-port/inspector.css
index 37e4117..8499ffe 100644
--- a/apps/web/src/components/editor-port/inspector.css
+++ b/apps/web/src/components/editor-port/inspector.css
@@ -112,6 +112,51 @@
.edit-plan__range button { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); }
.edit-plan__warning { margin: 7px 0 0; color: var(--warning, #d99a3e); font-size: 10px; }
+.pro-limit {
+ position: relative;
+ overflow: hidden;
+ margin: 10px 10px 0;
+ padding: 15px;
+ border: 1px solid color-mix(in srgb, #a78bfa 52%, var(--border));
+ border-radius: 12px;
+ background:
+ linear-gradient(145deg, color-mix(in srgb, #7c3aed 13%, var(--panel)), var(--panel));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
+}
+.pro-limit__glow {
+ position: absolute;
+ width: 130px;
+ height: 130px;
+ top: -80px;
+ right: -35px;
+ border-radius: 999px;
+ background: rgba(167, 139, 250, 0.24);
+ filter: blur(22px);
+ pointer-events: none;
+}
+.pro-limit__head {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.pro-limit__badge {
+ padding: 4px 7px;
+ border: 1px solid rgba(196, 181, 253, 0.4);
+ border-radius: 999px;
+ color: #c4b5fd;
+ background: rgba(124, 58, 237, 0.14);
+ font-size: 9px;
+ letter-spacing: 0.08em;
+}
+.pro-limit__safe { color: var(--chrome-lo); font-size: 9px; }
+.pro-limit h3 { position: relative; margin: 13px 0 6px; font-size: 15px; }
+.pro-limit p { position: relative; margin: 0; color: var(--ink-dim); font-size: 11px; line-height: 1.5; }
+.pro-limit ul { position: relative; margin: 12px 0; padding-left: 17px; color: var(--ink); font-size: 11px; line-height: 1.7; }
+.pro-limit li::marker { color: #a78bfa; }
+.pro-limit .pro-limit__hint { color: #c4b5fd; font-size: 9px; }
+
.pane__prompt {
font-family: var(--f-ui);
background: var(--recessed);
diff --git a/apps/web/src/hooks/useGenerationSession.ts b/apps/web/src/hooks/useGenerationSession.ts
index 554c4f2..597588e 100644
--- a/apps/web/src/hooks/useGenerationSession.ts
+++ b/apps/web/src/hooks/useGenerationSession.ts
@@ -14,6 +14,7 @@ import {
type JobStreamEvent,
type Variant,
type BBox,
+ ApiError,
} from "@/lib/api";
import type { Clip } from "@/stores/edl";
@@ -54,6 +55,10 @@ export function useGenerationSession({
const [plan, setPlan] = useState
(null);
const [planning, setPlanning] = useState(false);
const [fallbackNotice, setFallbackNotice] = useState(null);
+ const [proLimit, setProLimit] = useState<{
+ limitSeconds: number;
+ detectedSeconds: number | null;
+ } | null>(null);
const [adaptivePlanningEnabled, setAdaptivePlanningEnabled] = useState(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
@@ -65,6 +70,7 @@ export function useGenerationSession({
const jobIdRef = useRef(null);
const generationTargetRef = useRef(null);
const streamCtlRef = useRef(null);
+ const planBlockedRef = useRef(false);
const canGenerate =
!!clip &&
@@ -78,6 +84,8 @@ export function useGenerationSession({
function updatePrompt(value: string) {
setPrompt(value);
setFallbackNotice(null);
+ setProLimit(null);
+ planBlockedRef.current = false;
if (plan && plan.intent.raw_prompt !== value.trim()) setPlan(null);
}
@@ -96,9 +104,11 @@ export function useGenerationSession({
setResult(null);
jobIdRef.current = null;
generationTargetRef.current = null;
+ planBlockedRef.current = false;
setPlan(null);
setPlanning(false);
setFallbackNotice(null);
+ setProLimit(null);
if (!keepPrompt) setPrompt("");
}
@@ -135,6 +145,7 @@ export function useGenerationSession({
if (selectionId) {
const planned = await preparePlan(undefined, true);
if (planned) return true;
+ if (planBlockedRef.current) return false;
} else {
setFallbackNotice(
"Precise selection unavailable. Rendering with legacy fixed window.",
@@ -222,7 +233,9 @@ export function useGenerationSession({
allowLegacyFallback = false,
): Promise {
if (!clip?.projectId || !selectionId || !prompt.trim()) return false;
+ const baseClip = sourceClip ?? clip;
setPlanning(true);
+ planBlockedRef.current = false;
setErr(null);
setStatus("planning");
try {
@@ -232,6 +245,8 @@ export function useGenerationSession({
prompt: prompt.trim(),
explicit_start_ts: explicitRange?.start,
explicit_end_ts: explicitRange?.end,
+ source_start_ts: baseClip.sourceStart,
+ source_end_ts: baseClip.sourceEnd,
});
if (!next.adaptive_generation_enabled) {
setPlan(null);
@@ -247,8 +262,20 @@ export function useGenerationSession({
}
setPlan(next);
setFallbackNotice(null);
+ setProLimit(null);
return true;
} catch (error) {
+ if (error instanceof ApiError && error.code === "edit_window_too_long") {
+ planBlockedRef.current = true;
+ setFallbackNotice(null);
+ setProLimit({
+ limitSeconds: Number(error.detail?.limit_seconds ?? 30),
+ detectedSeconds: error.detail?.detected_seconds == null
+ ? null
+ : Number(error.detail.detected_seconds),
+ });
+ return false;
+ }
if (allowLegacyFallback) {
setFallbackNotice(
"Adaptive planning unavailable. Rendering with legacy fixed window.",
@@ -303,6 +330,7 @@ export function useGenerationSession({
plan,
planning,
fallbackNotice,
+ proLimit,
adaptivePlanningEnabled,
busy,
status,
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 156bbb2..70a9cd0 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -8,6 +8,18 @@ import { redirectToLogin } from "@/lib/auth";
const SESSION_KEY = "fiebatt.session_id";
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ public code?: string,
+ public detail?: Record,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
export function getSessionId(): string {
let sid = localStorage.getItem(SESSION_KEY);
if (!sid) {
@@ -33,8 +45,25 @@ async function request(
throw new Error("401 Unauthorized");
}
if (!res.ok) {
- const text = await res.text().catch(() => "");
- throw new Error(`${res.status} ${res.statusText}: ${text}`);
+ const payload = await res.json().catch(() => null) as {
+ detail?: string | Record;
+ } | null;
+ const detail = payload?.detail;
+ if (detail && typeof detail === "object") {
+ const message = typeof detail.message === "string"
+ ? detail.message
+ : `${res.status} ${res.statusText}`;
+ throw new ApiError(
+ res.status,
+ message,
+ typeof detail.code === "string" ? detail.code : undefined,
+ detail,
+ );
+ }
+ throw new ApiError(
+ res.status,
+ typeof detail === "string" ? detail : `${res.status} ${res.statusText}`,
+ );
}
return res.json();
}
@@ -166,6 +195,7 @@ export type EditPlanResp = {
intent: {
raw_prompt: string;
change_type: "appearance" | "removal" | "replacement" | "motion" | "scene";
+ duration_policy: "bounded_action" | "continuous_occurrence" | "explicit_range" | "all_occurrences";
action_phases: string[];
estimated_action_seconds: number;
requires_recovery_motion: boolean;
@@ -209,6 +239,8 @@ export type CreateEditPlanReq = {
prompt: string;
explicit_start_ts?: number;
explicit_end_ts?: number;
+ source_start_ts?: number;
+ source_end_ts?: number;
};
export type AcceptResp = {