返回 JoyAI-Echo
WorkplacePanel.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Check, ChevronLeft, ChevronRight, LoaderCircle } from "lucide-react";
3
4 import { Button } from "@/components/ui/button";
5 import { GenerationAlertDialog } from "@/components/ui/GenerationAlertDialog";
6 import { DEFAULT_VIDEO_SIZE } from "@/components/thread/AspectRatioPicker";
7 import { GENERATION_BUSY_ALERT_ENABLED } from "@/config/features";
8 import { useShotGenerationAlerts } from "@/hooks/useShotGenerationAlerts";
9 import { useWorkplaceContext } from "@/providers/WorkplaceProvider";
10 import type { WorkplaceData, WorkplaceShot } from "@/lib/types";
11 import { collectConfirmedMemoryEntries } from "@/lib/memory-bank-history";
12 import {
13 canGenerateShot,
14 isMemoryRecommendationReady,
15 missingReferenceGenerations,
16 previousShotApprovalMessage,
17 referenceDependencyMessage,
18 shotOneWorkflowHint,
19 shotReferenceIds,
20 } from "@/lib/workplace/generation";
21 import {
22 beatSummaryForShot,
23 buildBeatRevisionFeedback,
24 } from "@/lib/workplace/revision";
25 import type { CommitAction } from "@/hooks/workplace-editor/types";
26 import { beatPath } from "@/hooks/workplace-editor/types";
27 import { serverValueForPath } from "@/hooks/workplace-editor/reducer";
28 import type { UseWorkplaceEditorResult } from "@/hooks/workplace-editor/useWorkplaceEditor";
29 import { cn } from "@/lib/utils";
30 import { ScriptEditor } from "../longvideo/ScriptEditor";
31 import { StoryboardScriptEditor } from "../longvideo/StoryboardScriptEditor";
32 import {
33 FramesPanel,
34 FrameStatus,
35 type Shot1FirstFrameControls,
36 } from "../longvideo/FramesPanel";
37 import { RenderOverlay, type RenderStatus } from "../longvideo/RenderOverlay";
38 import { ComposePanel } from "../longvideo/ComposePanel";
39 import { StepConfirmDialog } from "../longvideo/StepConfirmDialog";
40 import type { StepId } from "../longvideo/StepHeader";
41 import { MemoryBankBoard } from "./MemoryBankBoard";
42 import {
43 ShotMemorySlots,
44 type ShotMemorySlotsHandle,
45 } from "./ShotMemorySlots";
46
47 /** After start-generation, wait this long for agent prep before offering retry. */
48 const SHOT_GENERATING_PREP_TIMEOUT_MS = 5 * 60 * 1000;
49
50 type WorkplaceTab = "story" | "shots";
51
52 type IrreversibleAction =
53 | "auto_generate"
54 | Extract<
55 CommitAction["type"],
56 "confirm_story" | "start_generation" | "start_merge"
57 >
58 | "generate_all";
59
60 /** 不可逆工作流动作 → StepConfirmDialog 目标步骤文案 */
61 const ACTION_TO_STEP: Record<IrreversibleAction, StepId> = {
62 confirm_story: 2,
63 start_generation: 3,
64 generate_all: 3,
65 auto_generate: 4,
66 start_merge: 4,
67 };
68
69 interface WorkplacePanelProps {
70 sessionKey: string | null;
71 activeTab: WorkplaceTab;
72 onTabChange: (tab: WorkplaceTab) => void;
73 onNewChat?: () => Promise<string | null>;
74 /** True when the left chat has messages but the story is still empty. */
75 showThinking?: boolean;
76 /** True while the left chat turn is still running; 02 workflow buttons wait. */
77 chatBusy?: boolean;
78 }
79
80 function hasUnsavedBeatChanges(
81 workplace: WorkplaceData,
82 editor: Pick<UseWorkplaceEditorResult, "segments">,
83 ): boolean {
84 return editor.segments.some((segment) => {
85 const baseline = serverValueForPath(workplace, beatPath(segment.shotId));
86 return segment.text.trim() !== baseline.trim();
87 });
88 }
89
90 function stageLabel(stage: string | null | undefined): string {
91 switch (stage) {
92 case "story_discussion":
93 return "Briefing";
94 case "story_confirmed":
95 return "Story approval";
96 case "shot_planning":
97 return "Shot planning";
98 case "shot_generating":
99 return "Shot generation";
100 case "shot_reviewing":
101 return "Shot review";
102 case "shot_revising":
103 return "Shot revision";
104 case "awaiting_memory_review":
105 return "Memory review";
106 case "awaiting_memory_build":
107 return "Memory build";
108 case "merging":
109 return "Final assembly";
110 case "done":
111 return "Complete";
112 default:
113 return "Preparing";
114 }
115 }
116
117 function workflowStepIndex(stage: string | null | undefined): number {
118 switch (stage) {
119 case "story_discussion":
120 case "story_confirmed":
121 return 0;
122 case "shot_planning":
123 return 1;
124 case "shot_generating":
125 case "shot_reviewing":
126 case "shot_revising":
127 case "awaiting_memory_review":
128 case "awaiting_memory_build":
129 return 2;
130 case "merging":
131 return 3;
132 case "done":
133 return 4;
134 default:
135 return 0;
136 }
137 }
138
139 function failedAutoGenerateShot(workplace: WorkplaceData | null) {
140 return workplace?.shots.find((shot) => shot.status === "error") ?? null;
141 }
142
143 function composeRenderStatus(workplace: WorkplaceData | null): RenderStatus {
144 if (!workplace) return "idle";
145 if (
146 workplace.stage === "done" ||
147 Boolean(workplace.final_output_url) ||
148 Boolean(workplace.final_video)
149 ) {
150 return "done";
151 }
152 if (
153 workplace.stage === "error" ||
154 workplace.stage === "failed" ||
155 failedAutoGenerateShot(workplace)
156 ) {
157 return "error";
158 }
159 if (workplace.stage === "awaiting_memory_review") {
160 return "idle";
161 }
162 if (workplace.auto_generate || workplace.stage === "merging") {
163 return "rendering";
164 }
165 return "idle";
166 }
167
168 function composeRenderError(workplace: WorkplaceData | null): string | undefined {
169 const failed = failedAutoGenerateShot(workplace);
170 const message =
171 (typeof workplace?.generation_error === "string" &&
172 workplace.generation_error) ||
173 failed?.generation_error ||
174 "";
175 return message.trim() || undefined;
176 }
177
178 function resolveWorkflowIndex(workplace: WorkplaceData | null): number {
179 const progress = workplace?.progress;
180 if (progress === "done") return 4;
181 // 一键成片不要跟 progress 02/03 走进逐镜打磨;重新生成过程中也停在合成页。
182 if (workplace?.auto_generate) {
183 if (
184 workplace.stage === "done" ||
185 workplace.final_output_url ||
186 workplace.final_video
187 ) {
188 return 4;
189 }
190 return 3;
191 }
192 // 逐镜打磨:Memory 审核 / 单镜失败仍停在 03,避免 progress 短暂报 02 打回分镜脚本。
193 if (
194 workplace?.stage === "awaiting_memory_review" ||
195 workplace?.stage === "awaiting_memory_build" ||
196 workplace?.stage === "failed" ||
197 workplace?.stage === "shot_generating" ||
198 workplace?.stage === "shot_reviewing" ||
199 workplace?.stage === "shot_revising"
200 ) {
201 return 2;
202 }
203 if (progress === "01") return 0;
204 if (progress === "02") return 1;
205 if (progress === "03") return 2;
206 if (progress === "04") return 3;
207 return workflowStepIndex(workplace?.stage);
208 }
209
210 function shotIdFromKey(shots: WorkplaceShot[], shotKey: string): number | null {
211 const shot = shots.find((item) => item.shot_key === shotKey);
212 return shot?.shot_id ?? null;
213 }
214
215 const WORKFLOW_STEPS = [
216 { id: 1, label: "Story" },
217 { id: 2, label: "Shot Plan" },
218 { id: 3, label: "Generate" },
219 { id: 4, label: "Final Cut" },
220 // { id: 5, label: "完成输出" },
221 ] as const;
222
223 /** 只读阶段条:步骤由 agent stage 驱动,视觉对齐 StepHeader。 */
224 function WorkflowStageCard({ workplace }: { workplace: WorkplaceData | null }) {
225 const stage = workplace?.stage ?? null;
226 const activeIndex = resolveWorkflowIndex(workplace);
227 const currentStep = activeIndex + 1;
228 const totalShots = workplace?.shots.length ?? 0;
229 const approvedShots =
230 workplace?.shots.filter((shot) => shot.status === "approved").length ?? 0;
231 const progress =
232 totalShots > 0 ? { done: approvedShots, total: totalShots } : null;
233 const progressPct =
234 progress && progress.total > 0
235 ? Math.round((progress.done / progress.total) * 100)
236 : null;
237 const showShotProgress = activeIndex >= 2 && totalShots > 0;
238
239 return (
240 <div
241 className="relative shrink-0 h-[100px]"
242 aria-label={`Current stage: ${stageLabel(stage)}`}
243 >
244 <div className="flex h-full items-center gap-1 px-3 py-2.5">
245 <Button
246 variant="ghost"
247 size="icon"
248 disabled
249 className="invisible h-7 w-7 rounded-lg"
250 aria-hidden
251 >
252 <ChevronLeft className="h-4 w-4" />
253 </Button>
254
255 <div className="flex flex-1 items-center justify-center gap-0">
256 {WORKFLOW_STEPS.map((step, idx) => {
257 const completed = step.id < currentStep;
258 const active = step.id === currentStep;
259 const reached = step.id <= currentStep;
260 return (
261 <div key={step.id} className="flex items-center">
262 {idx > 0 && (
263 <div className="relative mx-1.5 w-6">
264 <div className="absolute inset-y-1/2 h-px w-full bg-border/50" />
265 <div
266 className={cn(
267 "absolute inset-y-1/2 h-px bg-foreground/20 transition-all duration-500 ease-out",
268 reached ? "w-full" : "w-0",
269 )}
270 />
271 </div>
272 )}
273 <div
274 className={cn(
275 "group relative flex max-w-[8.5rem] flex-col items-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-300",
276 active &&
277 "bg-foreground/[0.05] ring-1 ring-inset ring-foreground/[0.07]",
278 )}
279 >
280 <span
281 className={cn(
282 "text-[13px] tabular-nums transition-all duration-300",
283 active && "font-medium text-foreground/70",
284 completed && "font-medium text-foreground/40",
285 !active &&
286 !completed &&
287 reached &&
288 "font-medium text-foreground/35",
289 !reached &&
290 !active &&
291 !completed &&
292 "font-normal text-muted-foreground/30",
293 )}
294 >
295 {completed ? (
296 <Check className="h-3 w-3" strokeWidth={2.5} />
297 ) : (
298 String(step.id).padStart(2, "0")
299 )}
300 </span>
301 <span
302 className={cn(
303 "text-center text-[13px] transition-all duration-300",
304 active && "font-medium text-foreground/85",
305 completed && "font-normal text-foreground/45",
306 !active &&
307 !completed &&
308 reached &&
309 "font-normal text-foreground/40",
310 !reached &&
311 !active &&
312 !completed &&
313 "font-normal text-muted-foreground/35",
314 )}
315 >
316 {step.label}
317 </span>
318
319 {active && showShotProgress && progressPct !== null ? (
320 <span className="mt-0.5 text-[13px] tabular-nums text-foreground/40">
321 {progressPct}%
322 </span>
323 ) : null}
324 </div>
325 </div>
326 );
327 })}
328 </div>
329
330 {/* {progress ? (
331 <span className="mr-1 shrink-0 text-[15px] tabular-nums text-muted-foreground/70">
332 {progress.done}/{progress.total}
333 </span>
334 ) : (
335 <span className="invisible mr-1 shrink-0 text-[15px]" aria-hidden>
336 0/0
337 </span>
338 )} */}
339
340 <Button
341 variant="ghost"
342 size="icon"
343 disabled
344 className="invisible h-7 w-7 rounded-lg"
345 aria-hidden
346 >
347 <ChevronRight className="h-4 w-4" />
348 </Button>
349 </div>
350
351 {progress && progress.total > 0 ? (
352 <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-border/40">
353 <div
354 className="h-full bg-foreground/30 transition-all duration-700 ease-out"
355 style={{ width: `${(progress.done / progress.total) * 100}%` }}
356 />
357 </div>
358 ) : (
359 <div className="h-px w-full bg-border/60" />
360 )}
361 </div>
362 );
363 }
364
365 const waitingCSS = `
366 @keyframes slate-fade-in {
367 from { opacity: 0; transform: translateY(12px); }
368 to { opacity: 1; transform: translateY(0); }
369 }
370 @keyframes status-pulse {
371 0%, 100% { opacity: 0.55; }
372 50% { opacity: 1; }
373 }
374 @keyframes bar-shimmer {
375 0% { transform: translateX(-100%); }
376 100% { transform: translateX(250%); }
377 }
378 `;
379 function StoryWorkspace({
380 story,
381 storyEmpty,
382 readOnly = false,
383 onChange,
384 onFocus,
385 onBlur,
386 onApplyNext,
387 applyNextDisabled = false,
388 applyNextLabel = "Next Step",
389 onSave,
390 saveDisabled,
391 saving = false,
392 showThinking = false,
393 }: {
394 story: string;
395 storyEmpty: boolean;
396 readOnly?: boolean;
397 onChange: (text: string) => void;
398 onFocus?: () => void;
399 onBlur?: () => void;
400 onApplyNext: () => void;
401 applyNextDisabled?: boolean;
402 applyNextLabel?: string;
403 onSave?: () => void | Promise<void>;
404 saveDisabled?: boolean;
405 saving?: boolean;
406 showThinking?: boolean;
407 }) {
408 if (!story.trim() && storyEmpty) {
409 return (
410 <div className="flex h-full flex-col items-center justify-center px-10">
411 <style>{waitingCSS}</style>
412
413 {/* Clapperboard card */}
414 <div
415 className="w-full max-w-[320px] rounded-xl border border-border/70 bg-card/40 backdrop-blur-sm"
416 style={{ animation: "slate-fade-in 0.5s ease-out both" }}
417 >
418 {/* Slate top bar */}
419 <div className="flex items-center border-b border-border/50 px-5 py-3">
420 <span className="text-[13px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
421 Production
422 </span>
423 </div>
424
425 {/* Fields */}
426 <div className="space-y-3 px-5 py-4">
427 <div className="flex items-baseline gap-3">
428 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
429 Scene
430 </span>
431 <div className="h-px flex-1 bg-border/60" />
432 </div>
433 <div className="flex items-baseline gap-3">
434 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
435 Take
436 </span>
437 <div className="h-px flex-1 bg-border/60" />
438 </div>
439 <div className="flex items-baseline gap-3">
440 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
441 Director
442 </span>
443 <div className="h-px flex-1 bg-border/60" />
444 </div>
445 </div>
446
447 {/* Status section */}
448 {/* 如果 messages 里面有消息了,则展示构思中 */}
449 {showThinking ? (
450 <div className="border-t border-border/50 px-5 py-4">
451 <div className="flex items-center justify-center">
452 <span
453 className="text-[13px] font-medium text-foreground/70"
454 style={{
455 animation: "status-pulse 2.4s ease-in-out infinite",
456 }}
457 >
458 Developing story...
459 </span>
460 </div>
461
462 {/* Progress bar */}
463 <div className="relative mt-3 h-[3px] overflow-hidden rounded-full bg-border/50">
464 <div
465 className="absolute inset-y-0 left-0 w-[28%] rounded-full bg-foreground/20"
466 style={{ animation: "bar-shimmer 2.2s ease-in-out infinite" }}
467 />
468 </div>
469 </div>
470 ) : null}
471 </div>
472
473 {/* Subtitle text */}
474 <p
475 className="mt-6 text-center text-[14px] leading-relaxed text-muted-foreground/60"
476 style={{ animation: "slate-fade-in 0.5s ease-out 0.2s both" }}
477 >
478 Describe your idea in the conversation. The script will appear here.
479 </p>
480 </div>
481 );
482 }
483 return (
484 <ScriptEditor
485 value={story}
486 onChange={onChange}
487 onFocus={onFocus}
488 onBlur={onBlur}
489 readOnly={readOnly}
490 onApplyNext={onApplyNext}
491 applyNextDisabled={applyNextDisabled}
492 applyNextLabel={applyNextLabel}
493 onSave={onSave}
494 saveDisabled={saveDisabled}
495 saving={saving}
496 />
497 );
498 }
499
500 // function ShotCard({
501 // shot,
502 // busy,
503 // onAccept,
504 // onRevise,
505 // }: {
506 // shot: WorkplaceShot;
507 // busy: boolean;
508 // onAccept: () => Promise<void>;
509 // onRevise: (feedback: string) => Promise<void>;
510 // }) {
511 // const [editing, setEditing] = useState(false);
512 // const [feedback, setFeedback] = useState("");
513 // const [localError, setLocalError] = useState<string | null>(null);
514 // const [revisionSubmitted, setRevisionSubmitted] = useState(false);
515 // const canReviewShot = ["generated", "review_pass"].includes(shot.status);
516 // const serverRevisionPending = ["review_fail", "queued"].includes(shot.status);
517 // const revisionLocked = revisionSubmitted || serverRevisionPending;
518 // const showActions = shot.has_actions && canReviewShot && !revisionLocked;
519
520 // useEffect(() => {
521 // if (revisionSubmitted && !canReviewShot) {
522 // setRevisionSubmitted(false);
523 // }
524 // }, [canReviewShot, revisionSubmitted]);
525
526 // useEffect(() => {
527 // if (revisionLocked) {
528 // setEditing(false);
529 // }
530 // }, [revisionLocked]);
531
532 // const timelineLabel = useMemo(
533 // () =>
534 // `${shot.timeline.label} · ${formatDuration(shot.timeline.duration_seconds)}`,
535 // [shot.timeline.duration_seconds, shot.timeline.label],
536 // );
537
538 // return (
539 // <div className="group relative pl-10">
540 // <div className="absolute left-[0.8rem] top-4 h-full w-px bg-gradient-to-b from-border via-border/70 to-transparent" />
541 // <div className="absolute left-0 top-3 flex h-7 w-7 items-center justify-center rounded-full border border-border/70 bg-background shadow-sm">
542 // <span className="text-[11px] font-semibold text-foreground/82">
543 // {shot.shot_id}
544 // </span>
545 // </div>
546
547 // <article
548 // className={cn(
549 // "animate-in fade-in-0 slide-in-from-top-2 rounded-[1.6rem] border border-border/70 bg-background/92 p-4 shadow-[0_22px_65px_-48px_rgba(15,23,42,0.7)] backdrop-blur",
550 // "transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_30px_80px_-46px_rgba(15,23,42,0.82)]",
551 // )}
552 // >
553 // <div className="mb-3 flex items-start justify-between gap-3">
554 // <div className="min-w-0">
555 // <p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
556 // Shot {shot.shot_id}
557 // </p>
558 // <p className="mt-1 text-sm leading-6 text-foreground/92">
559 // {shot.summary || "等待 agent 补充分镜说明"}
560 // </p>
561 // </div>
562 // <span className="rounded-full border border-border/70 bg-muted/45 px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
563 // {timelineLabel}
564 // </span>
565 // </div>
566
567 // {shot.video?.url ? (
568 // <div className="overflow-hidden rounded-[1.2rem] border border-border/70 bg-black/90">
569 // <video
570 // src={shot.video.url}
571 // controls
572 // preload="metadata"
573 // className="aspect-video w-full bg-black object-cover"
574 // />
575 // </div>
576 // ) : isShotWaitingForVideo(shot) ? (
577 // <ShotLoadingPreview />
578 // ) : null}
579
580 // {showActions ? (
581 // <div className="mt-4 rounded-[1.2rem] border border-border/70 bg-muted/20 p-3">
582 // <div className="flex flex-wrap items-center gap-2">
583 // <Button
584 // size="sm"
585 // onClick={() => void onAccept()}
586 // disabled={busy}
587 // className="rounded-full"
588 // >
589 // <Check className="mr-1.5 h-4 w-4" />
590 // 接受
591 // </Button>
592 // <Button
593 // size="sm"
594 // variant="outline"
595 // onClick={() => {
596 // setEditing((value) => !value);
597 // setLocalError(null);
598 // }}
599 // disabled={busy}
600 // className="rounded-full"
601 // >
602 // 修改
603 // </Button>
604 // </div>
605
606 // {editing ? (
607 // <div className="mt-3 space-y-2">
608 // <Textarea
609 // value={feedback}
610 // onChange={(event) => setFeedback(event.target.value)}
611 // placeholder="填写需要修改的镜头节奏、画面、人物动作或台词意见"
612 // className="min-h-[88px] resize-y rounded-2xl"
613 // />
614 // {localError ? (
615 // <p className="text-xs text-destructive">{localError}</p>
616 // ) : null}
617 // <div className="flex items-center gap-2">
618 // <Button
619 // size="sm"
620 // variant="outline"
621 // disabled={busy}
622 // onClick={() => {
623 // setEditing(false);
624 // setFeedback("");
625 // setLocalError(null);
626 // }}
627 // className="rounded-full"
628 // >
629 // 取消
630 // </Button>
631 // <Button
632 // size="sm"
633 // disabled={busy}
634 // onClick={() => {
635 // const trimmed = feedback.trim();
636 // if (!trimmed) {
637 // setLocalError("请先填写修改意见");
638 // return;
639 // }
640 // setLocalError(null);
641 // setRevisionSubmitted(true);
642 // setEditing(false);
643 // setFeedback("");
644 // void onRevise(trimmed).catch(() => {
645 // setRevisionSubmitted(false);
646 // });
647 // }}
648 // className="rounded-full"
649 // >
650 // 提交修改
651 // </Button>
652 // </div>
653 // </div>
654 // ) : null}
655 // </div>
656 // ) : null}
657
658 // {shot.status === "approved" ? (
659 // <div className="mt-4 rounded-[1.2rem] border border-emerald-500/20 bg-emerald-500/8 px-3 py-2 text-sm text-emerald-800 dark:text-emerald-200">
660 // 这个 shot 已经被接受,后续会参与最终合成。
661 // </div>
662 // ) : null}
663
664 // {shot.review_notes ? (
665 // <div className="mt-3 rounded-2xl bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
666 // {shot.review_notes}
667 // </div>
668 // ) : null}
669
670 // <div className="mt-4 flex items-end justify-between gap-3">
671 // <span
672 // className={cn(
673 // "inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ring-inset",
674 // statusTone(shot.status),
675 // )}
676 // >
677 // {statusLabel(shot.status)}
678 // </span>
679 // <span className="text-[11px] text-muted-foreground">
680 // {shot.cut ? "新镜头切点" : "承接上一镜"}
681 // </span>
682 // </div>
683 // </article>
684 // </div>
685 // );
686 // }
687
688 // function ShotsWorkspace({
689 // shots,
690 // finalVideo,
691 // generationStarted,
692 // busyShotId,
693 // onAccept,
694 // onRevise,
695 // }: {
696 // shots: WorkplaceShot[];
697 // finalVideo?: WireMediaRef | null;
698 // generationStarted: boolean;
699 // busyShotId: number | null;
700 // onAccept: (shotId: number) => Promise<void>;
701 // onRevise: (shotId: number, feedback: string) => Promise<void>;
702 // }) {
703 // if (shots.length === 0) {
704 // if (!generationStarted) return null;
705 // return <ShotLoadingPreview />;
706 // }
707
708 // return (
709 // <div className="animate-in fade-in-0 slide-in-from-top-3 space-y-4 pb-6 duration-500">
710 // {shots.map((shot) => (
711 // <ShotCard
712 // key={shot.shot_key}
713 // shot={shot}
714 // busy={busyShotId === shot.shot_id}
715 // onAccept={() => onAccept(shot.shot_id)}
716 // onRevise={(feedback) => onRevise(shot.shot_id, feedback)}
717 // />
718 // ))}
719 // {finalVideo?.url ? <FinalVideoCard video={finalVideo} /> : null}
720 // </div>
721 // );
722 // }
723
724 export function WorkplacePanel({
725 sessionKey,
726 activeTab: _activeTab,
727 onTabChange: _onTabChange,
728 onNewChat,
729 showThinking = false,
730 chatBusy = false,
731 }: WorkplacePanelProps) {
732 const {
733 workplace,
734 loading,
735 error,
736 workflowBusy,
737 splitMergeBusy,
738 mutatingShotId,
739 accept,
740 acceptAll,
741 acceptAllBusy,
742 revise,
743 generateOneShot,
744 continuousGenerateOneShot,
745 setContinuousMode,
746 generateAll,
747 promptOverrides,
748 updateShotDuration,
749 regenerate,
750 abortGeneration,
751 startAutoGenerate,
752 editor,
753 setAuxTextEditing,
754 updateEchoLike,
755 recordEchoDownloadPrompt,
756 memoryWorkspaceBusy,
757 saveMemoryAsset,
758 createShotMemoryAsset,
759 deleteMemoryAsset,
760 saveShotMemorySlots,
761 } = useWorkplaceContext();
762
763 const [showOverlay, setShowOverlay] = useState(false);
764 // 不可逆工作流动作统一经 StepConfirmDialog 门控,避免误触直接 commit
765 const [pendingConfirm, setPendingConfirm] =
766 useState<IrreversibleAction | null>(null);
767 const [shotCountAlertOpen, setShotCountAlertOpen] = useState(false);
768
769 const [shotEditAlertOpen, setShotEditAlertOpen] = useState(false);
770 const [generationCongestedOpen, setGenerationCongestedOpen] = useState(false);
771 const generationCongestedShownRef = useRef(false);
772 const shotMemorySlotsRefs = useRef(
773 new Map<number, ShotMemorySlotsHandle>(),
774 );
775 const generationAlerts = useShotGenerationAlerts(workplace, sessionKey);
776
777 const shot1VideoSize = useMemo(() => {
778 const width = Number(workplace?.goal?.width);
779 const height = Number(workplace?.goal?.height);
780 if (
781 Number.isFinite(width) &&
782 Number.isFinite(height) &&
783 width > 0 &&
784 height > 0
785 ) {
786 return { width, height };
787 }
788 return DEFAULT_VIDEO_SIZE;
789 }, [workplace?.goal?.height, workplace?.goal?.width]);
790
791 const step = resolveWorkflowIndex(workplace);
792 const shots = workplace?.shots ?? [];
793 const memoryWorkspaceAssets = useMemo(
794 () =>
795 workplace?.memory_workspace_assets ??
796 collectConfirmedMemoryEntries(workplace).map((entry, index) => ({
797 asset_id: `legacy-${entry.memory_id}-${entry.source_shot_id}-${entry.frame_index}-${index}`,
798 display_name: entry.display_name ?? entry.memory_id,
799 source: "automatic" as const,
800 kind: entry.kind,
801 memory_id: entry.memory_id,
802 source_shot_id: entry.source_shot_id,
803 frame_index: entry.frame_index,
804 image: entry.image,
805 audio: entry.audio,
806 })),
807 [workplace],
808 );
809
810 const frames = useMemo(
811 () =>
812 workplace?.shots.map((shot) => {
813 const memoryRecommendationReady = workplace
814 ? isMemoryRecommendationReady(workplace, shot)
815 : false;
816 const memoryNeedsApply =
817 shot.shot_id > 1 &&
818 workplace?.stage === "awaiting_memory_build" &&
819 memoryRecommendationReady &&
820 shot.memory_slots_configured !== true;
821 const referenceShotIds = shotReferenceIds(shot);
822 const missing = workplace
823 ? missingReferenceGenerations(workplace, shot)
824 : [];
825 const approvalMessage = workplace
826 ? previousShotApprovalMessage(workplace, shot)
827 : null;
828 const shotOneHint =
829 shot.shot_id === 1 && workplace
830 ? shotOneWorkflowHint(workplace)
831 : null;
832 return {
833 id: shot.shot_key,
834 shotId: shot.shot_id,
835 cut: shot.cut,
836 caption: shot.caption ?? "",
837 numFrames: shot.num_frames ?? undefined,
838 segmentText: editor.get(`beat:${shot.shot_id}`) ?? shot.summary ?? "",
839 prompt: promptOverrides[shot.shot_id] ?? shot.summary ?? "",
840 status: shot.status as FrameStatus,
841 videoUrl: shot.video?.url ?? "",
842 error: shot.generation_error || undefined,
843 durationSec: shot.timeline.duration_seconds ?? 5,
844 referenceShotIds,
845 referenceNote: shot.reference_selection_note ?? "",
846 canGenerate: workplace ? canGenerateShot(workplace, shot) : false,
847 dependencyMessage:
848 approvalMessage ??
849 (!memoryRecommendationReady
850 ? "Memory will unlock after the Agent finishes its recommendation."
851 : memoryNeedsApply
852 ? "Review and apply the recommended Memory before generating."
853 : null) ??
854 (missing.length > 0
855 ? referenceDependencyMessage(shot, missing)
856 : undefined),
857 hintMessage: shotOneHint ?? undefined,
858 hasActions: shot.has_actions,
859 reviewNotes: shot.review_notes || undefined,
860 accepted: shot.accepted,
861 generationMemories: shot.generation_memories ?? [],
862 continuousEnabled: shot.continuous_enabled ?? false,
863 };
864 }) ?? [],
865 [editor, promptOverrides, workplace],
866 );
867
868 const shotGenerationBusy = loading || workflowBusy || mutatingShotId !== null;
869
870 const busyFrameId = useMemo(() => {
871 if (mutatingShotId === null) return null;
872 return (
873 shots.find((shot) => shot.shot_id === mutatingShotId)?.shot_key ?? null
874 );
875 }, [mutatingShotId, shots]);
876
877 const handleConfirmStory = useCallback(() => {
878 if (editor.storyDirty) {
879 setShotEditAlertOpen(true);
880 return;
881 }
882 if (!workplace?.goal?.shot_count) {
883 setShotCountAlertOpen(true);
884 return;
885 }
886 setPendingConfirm("confirm_story");
887 }, [editor.storyDirty, workplace?.goal?.shot_count]);
888
889 /** 分镜脚本阶段「进入逐镜打磨」→ start-generation。 */
890 const handleStoryboardNext = useCallback(() => {
891 if (workplace && hasUnsavedBeatChanges(workplace, editor)) {
892 setShotEditAlertOpen(true);
893 return;
894 }
895 setPendingConfirm("start_generation");
896 }, [workplace, editor.segments]);
897
898 /** 分镜脚本阶段「确认并一键成片」→ workflow/auto-generate。 */
899 const handleStoryboardAutoGenerate = useCallback(() => {
900 if (workplace && hasUnsavedBeatChanges(workplace, editor)) {
901 setShotEditAlertOpen(true);
902 return;
903 }
904 setPendingConfirm("auto_generate");
905 }, [workplace, editor.segments]);
906
907 /** FramesPanel「全部生成」→ generate-all(同步提交 Echo 任务)。 */
908 const handleGenerateAll = useCallback(() => {
909 void generateAll();
910 }, [generateAll]);
911
912 /** FramesPanel「全部接收」→ accept-all(批量确认分镜)。 */
913 const handleAcceptAll = useCallback(() => {
914 void acceptAll();
915 }, [acceptAll]);
916
917 const handleStartMerge = useCallback(() => {
918 setPendingConfirm("start_merge");
919 }, []);
920
921 const handleStepConfirm = useCallback(() => {
922 if (!pendingConfirm) return;
923 const action = pendingConfirm;
924 setPendingConfirm(null);
925 switch (action) {
926 case "confirm_story":
927 void editor.commit({ type: "confirm_story" });
928 break;
929 case "start_generation":
930 void editor.commit({ type: "start_generation" });
931 break;
932 case "auto_generate":
933 void startAutoGenerate();
934 break;
935 case "generate_all":
936 void generateAll();
937 break;
938 case "start_merge":
939 void editor.commit({ type: "start_merge" });
940 setShowOverlay(true);
941 break;
942 }
943 }, [
944 editor,
945 generateAll,
946 pendingConfirm,
947 startAutoGenerate,
948 ]);
949
950 const handleStepCancel = useCallback(() => {
951 setPendingConfirm(null);
952 }, []);
953
954 /** FramesPanel「下一步」:触发合成并展示全屏 RenderOverlay。 */
955 const handleCompose = useCallback(() => {
956 handleStartMerge();
957 }, [handleStartMerge]);
958
959 const handleDismissOverlay = useCallback(() => {
960 setShowOverlay(false);
961 }, []);
962
963 useEffect(() => {
964 setShowOverlay(false);
965 setPendingConfirm(null);
966 setShotCountAlertOpen(false);
967 setShotEditAlertOpen(false);
968 setGenerationCongestedOpen(false);
969 generationCongestedShownRef.current = false;
970 }, [sessionKey]);
971
972 /**
973 * start-generation 后 agent 写 caption / 设 references 若超过 5 分钟仍未就绪,
974 * 提示服务器拥挤并允许回退到 shot_planning 重试。已进入可生成态则不计时。
975 */
976 useEffect(() => {
977 if (
978 !GENERATION_BUSY_ALERT_ENABLED ||
979 workplace?.stage !== "shot_generating" ||
980 workplace.references_ready
981 ) {
982 if (workplace?.stage !== "shot_generating") {
983 generationCongestedShownRef.current = false;
984 }
985 return;
986 }
987 if (generationCongestedShownRef.current) return;
988
989 const startedRaw = workplace.shot_generating_started_at;
990 const startedMs = startedRaw ? Date.parse(startedRaw) : Number.NaN;
991 const anchor = Number.isFinite(startedMs) ? startedMs : Date.now();
992 const remaining = Math.max(
993 0,
994 SHOT_GENERATING_PREP_TIMEOUT_MS - (Date.now() - anchor),
995 );
996
997 const timer = window.setTimeout(() => {
998 generationCongestedShownRef.current = true;
999 setGenerationCongestedOpen(true);
1000 }, remaining);
1001
1002 return () => window.clearTimeout(timer);
1003 }, [
1004 workplace?.stage,
1005 workplace?.references_ready,
1006 workplace?.shot_generating_started_at,
1007 sessionKey,
1008 ]);
1009
1010 const handleGenerationCongestedRetry = useCallback(() => {
1011 setGenerationCongestedOpen(false);
1012 void abortGeneration().catch(() => {
1013 // error 已写入 workplace.error;保持弹窗关闭避免重复打扰
1014 });
1015 }, [abortGeneration]);
1016
1017 const mergeRenderStatus = useMemo((): RenderStatus => {
1018 const status = composeRenderStatus(workplace);
1019 if (status !== "idle") return status;
1020 if (showOverlay) return "rendering";
1021 return "idle";
1022 }, [showOverlay, workplace]);
1023
1024 /** 成片后回到分镜编辑:清空 final_video,stage → shot_planning */
1025 const handleRegenerate = useCallback(() => {
1026 void regenerate().then(() => setShowOverlay(false));
1027 }, [regenerate]);
1028
1029 /** RenderOverlay onRetry:成片完成走 regenerate,合成失败仍 startMerge */
1030 const handleMergeOverlayRetry = useCallback(() => {
1031 if (mergeRenderStatus === "done") {
1032 handleRegenerate();
1033 return;
1034 }
1035 handleStartMerge();
1036 }, [mergeRenderStatus, handleRegenerate, handleStartMerge]);
1037
1038 /** ComposePanel onRetry:与 RenderOverlay 分支逻辑一致 */
1039 const handleComposeRetry = useCallback(() => {
1040 if (workplace?.stage === "done") {
1041 handleRegenerate();
1042 return;
1043 }
1044 const failed = failedAutoGenerateShot(workplace);
1045 if (workplace?.auto_generate && failed) {
1046 void generateOneShot(failed.shot_id);
1047 return;
1048 }
1049 handleStartMerge();
1050 }, [
1051 generateOneShot,
1052 handleRegenerate,
1053 handleStartMerge,
1054 workplace,
1055 ]);
1056
1057 const handleSplitAt = useCallback(
1058 (segmentId: string, cursorPos: number, segmentText: string) => {
1059 const segment = editor.segments.find((item) => item.id === segmentId);
1060 if (!segment) return;
1061 const before = segmentText.slice(0, cursorPos).trimEnd();
1062 const after = segmentText.slice(cursorPos).trimStart();
1063 if (!before || !after) return;
1064 void editor.commit({
1065 type: "split_shot",
1066 shotId: segment.shotId,
1067 beforeText: before,
1068 afterText: after,
1069 });
1070 },
1071 [editor],
1072 );
1073
1074 const handleMergeUp = useCallback(
1075 (lowerSegmentId: string, mergedText: string) => {
1076 const segment = editor.segments.find(
1077 (item) => item.id === lowerSegmentId,
1078 );
1079 if (!segment) return;
1080 void editor.commit({
1081 type: "merge_shot",
1082 shotId: segment.shotId,
1083 mergedText,
1084 });
1085 },
1086 [editor],
1087 );
1088
1089 const handleDeleteSegment = useCallback(
1090 (segmentId: string) => {
1091 const segment = editor.segments.find((item) => item.id === segmentId);
1092 if (!segment) return;
1093 if (
1094 editor.beatsReadOnly ||
1095 splitMergeBusy ||
1096 editor.segments.length <= 1
1097 ) {
1098 return;
1099 }
1100 void editor.commit({ type: "delete_shot", shotId: segment.shotId });
1101 },
1102 [editor, splitMergeBusy],
1103 );
1104
1105 const handleRetryFrame = useCallback(
1106 (frameId: string) => {
1107 const shotId = shotIdFromKey(shots, frameId);
1108 if (shotId === null) return;
1109 void revise(shotId, "Please regenerate.");
1110 },
1111 [revise, shots],
1112 );
1113
1114 const handleAcceptFrame = useCallback(
1115 (frameId: string) => {
1116 const shotId = shotIdFromKey(shots, frameId);
1117 if (shotId === null) return;
1118 void accept(shotId);
1119 },
1120 [accept, shots],
1121 );
1122
1123 const handleReviseFrame = useCallback(
1124 async (frameId: string, feedback: string) => {
1125 const shotId = shotIdFromKey(shots, frameId);
1126 if (shotId === null) return;
1127 await shotMemorySlotsRefs.current.get(shotId)?.applyPending();
1128 await revise(shotId, feedback);
1129 },
1130 [revise, shots],
1131 );
1132
1133 const handleGenerateShot = useCallback(
1134 (frameId: string) => {
1135 if (!workplace) return;
1136 const shot = workplace.shots.find((item) => item.shot_key === frameId);
1137 if (!shot) return;
1138 const missing = missingReferenceGenerations(workplace, shot);
1139 if (missing.length > 0) return;
1140
1141 if (shot.continuous_enabled && shot.shot_id > 1) {
1142 void continuousGenerateOneShot(shot.shot_id);
1143 return;
1144 }
1145
1146 void generateOneShot(shot.shot_id);
1147 },
1148 [
1149 continuousGenerateOneShot,
1150 generateOneShot,
1151 workplace,
1152 ],
1153 );
1154
1155 const shot1FirstFrameControls = useMemo<Shot1FirstFrameControls | null>(() => {
1156 const displayUrl = workplace?.reference_image?.url ?? null;
1157 if (!displayUrl) return null;
1158 return {
1159 displayUrl,
1160 videoSize: shot1VideoSize,
1161 };
1162 }, [shot1VideoSize, workplace?.reference_image?.url]);
1163
1164 const handleSetContinuousMode = useCallback(
1165 (frameId: string, enabled: boolean) => {
1166 if (!workplace) return;
1167 const shot = workplace.shots.find((item) => item.shot_key === frameId);
1168 if (!shot) return;
1169 void setContinuousMode(shot.shot_id, enabled);
1170 },
1171 [setContinuousMode, workplace],
1172 );
1173
1174 const handleUpdatePrompt = useCallback(
1175 (frameId: string, prompt: string) => {
1176 if (!workplace) return;
1177 const shotId = shotIdFromKey(shots, frameId);
1178 if (shotId === null) return;
1179 const trimmed = prompt.trim();
1180 if (!trimmed) return;
1181 const shot = workplace.shots.find((item) => item.shot_id === shotId);
1182 const oldSummary =
1183 beatSummaryForShot(workplace, shotId) || shot?.summary?.trim() || "";
1184 if (trimmed === oldSummary) return;
1185 const feedback = buildBeatRevisionFeedback(shotId, oldSummary, trimmed);
1186 void revise(shotId, feedback);
1187 },
1188 [revise, shots, workplace],
1189 );
1190
1191 const handleUpdateDuration = useCallback(
1192 (frameId: string, durationSec: number) => {
1193 const shotId = shotIdFromKey(shots, frameId);
1194 if (shotId === null) return;
1195 const shot = shots.find((item) => item.shot_id === shotId);
1196 const current = shot?.timeline.duration_seconds;
1197 if (current === durationSec) return;
1198 void updateShotDuration(shotId, durationSec);
1199 },
1200 [shots, updateShotDuration],
1201 );
1202
1203 const storyValue = editor.get("story");
1204 const storyBusy = workflowBusy || editor.committing;
1205 const generationBusy = storyBusy || splitMergeBusy || workflowBusy || chatBusy;
1206
1207 // confirm-story 后 stage 可能停在 story_confirmed,分镜尚未落盘
1208 const storyWaitingForShots =
1209 Boolean(workplace?.story_confirmed) && shots.length === 0;
1210
1211 const storyApplyLabel = useMemo(() => {
1212 // if (storyWaitingForShots) {
1213 // const progress = workplace?.shot_prompts_progress;
1214 // if (progress && progress.total > 0 && progress.ready < progress.total) {
1215 // return `生成分镜脚本中 (${progress.ready}/${progress.total})...`;
1216 // }
1217 // return "生成分镜脚本中...";
1218 // }
1219 if (workflowBusy) return "Approving story...";
1220 return "Next Step";
1221 }, [storyWaitingForShots, workflowBusy, workplace?.shot_prompts_progress]);
1222
1223 const beatsApplyLabel = useMemo(() => {
1224 if (!workflowBusy || workplace?.shot_prompts_ready) return "Next Step";
1225 const progress = workplace?.shot_prompts_progress;
1226 if (progress && progress.total > 0 && progress.ready < progress.total) {
1227 return `Preparing shot prompts (${progress.ready}/${progress.total})...`;
1228 }
1229 return "Preparing shot prompts...";
1230 }, [
1231 workflowBusy,
1232 workplace?.shot_prompts_progress,
1233 workplace?.shot_prompts_ready,
1234 ]);
1235
1236 const workflowContent = useMemo(() => {
1237 if (!sessionKey || !workplace) return null;
1238
1239 switch (step) {
1240 case 0:
1241 return (
1242 <StoryWorkspace
1243 story={storyValue}
1244 storyEmpty={workplace.story_empty}
1245 showThinking={showThinking}
1246 readOnly={editor.storyReadOnly}
1247 onChange={(text) => editor.edit("story", text)}
1248 onFocus={() => editor.hold("story")}
1249 onBlur={() => editor.release("story")}
1250 onSave={() => void editor.saveStory()}
1251 saveDisabled={
1252 editor.storyReadOnly || !editor.storyDirty || editor.committing
1253 }
1254 saving={editor.committing}
1255 onApplyNext={handleConfirmStory}
1256 applyNextLabel={storyApplyLabel}
1257 applyNextDisabled={
1258 storyBusy ||
1259 // storyWaitingForShots ||
1260 !storyValue.trim()
1261 }
1262 />
1263 );
1264 case 1:
1265 return (
1266 <StoryboardScriptEditor
1267 segments={editor.segments.map(({ id, text }) => ({ id, text }))}
1268 readOnly={editor.beatsReadOnly}
1269 onSegmentChange={(segmentId, text) => {
1270 const segment = editor.segments.find(
1271 (item) => item.id === segmentId,
1272 );
1273 if (!segment) return;
1274 editor.edit(`beat:${segment.shotId}`, text);
1275 }}
1276 onDeleteSegment={handleDeleteSegment}
1277 onSegmentFocus={(segmentId) => {
1278 const segment = editor.segments.find(
1279 (item) => item.id === segmentId,
1280 );
1281 if (!segment) return;
1282 editor.hold(`beat:${segment.shotId}`);
1283 }}
1284 onSegmentBlur={(segmentId) => {
1285 const segment = editor.segments.find(
1286 (item) => item.id === segmentId,
1287 );
1288 if (!segment) return;
1289 editor.release(`beat:${segment.shotId}`);
1290 }}
1291 onSave={() => void editor.saveBeats()}
1292 saveDisabled={
1293 editor.beatsReadOnly || !editor.beatsDirty || editor.committing
1294 }
1295 saving={editor.committing}
1296 onApplyNext={handleStoryboardNext}
1297 applyNextLabel={
1298 workflowBusy && !workplace?.shot_prompts_ready
1299 ? beatsApplyLabel
1300 : "Enter Shot Workshop"
1301 }
1302 onAutoGenerate={handleStoryboardAutoGenerate}
1303 autoGenerateLabel="Approve & Auto Generate"
1304 onSplitAt={handleSplitAt}
1305 onMergeUp={handleMergeUp}
1306 applyNextDisabled={
1307 generationBusy || editor.segments.length === 0
1308 }
1309 splitMergeDisabled={
1310 splitMergeBusy || storyBusy || editor.beatsReadOnly
1311 }
1312 />
1313 );
1314 case 2:
1315 return (
1316 <FramesPanel
1317 frames={frames}
1318 memoryBank={
1319 !workplace.auto_generate ? (
1320 <MemoryBankBoard
1321 assets={memoryWorkspaceAssets}
1322 shots={shots}
1323 busy={memoryWorkspaceBusy}
1324 showSlots={false}
1325 onSaveAsset={saveMemoryAsset}
1326 onDeleteAsset={deleteMemoryAsset}
1327 onApplySlots={saveShotMemorySlots}
1328 />
1329 ) : null
1330 }
1331 renderMemorySlots={
1332 !workplace.auto_generate
1333 ? (frameId) => {
1334 const shotIndex = shots.findIndex(
1335 (candidate) => candidate.shot_key === frameId,
1336 );
1337 const shot = shots[shotIndex];
1338 const recommendationReady = shot
1339 ? isMemoryRecommendationReady(workplace, shot)
1340 : false;
1341 const previousShot = shotIndex > 0
1342 ? shots[shotIndex - 1]
1343 : null;
1344 const conditionImage = shot?.shot_id === 1
1345 ? workplace.reference_image ?? null
1346 : shot?.continuous_enabled && previousShot?.tail_frame_url
1347 ? {
1348 url: previousShot.tail_frame_url,
1349 name: `Shot ${previousShot.shot_id} tail frame`,
1350 }
1351 : null;
1352 if (shot && !recommendationReady) {
1353 return (
1354 <section
1355 aria-label={`Memory recommendation pending for Shot ${shot.shot_id}`}
1356 className="flex items-center gap-2 rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
1357 >
1358 <LoaderCircle className="size-3.5 animate-spin" aria-hidden />
1359 <span>Memory is folded while the Agent prepares its recommendation.</span>
1360 </section>
1361 );
1362 }
1363 return shot ? (
1364 <ShotMemorySlots
1365 ref={(editor) => {
1366 if (editor) {
1367 shotMemorySlotsRefs.current.set(shot.shot_id, editor);
1368 } else {
1369 shotMemorySlotsRefs.current.delete(shot.shot_id);
1370 }
1371 }}
1372 assets={memoryWorkspaceAssets}
1373 shot={shot}
1374 conditionImage={conditionImage}
1375 busy={memoryWorkspaceBusy}
1376 onApplySlots={saveShotMemorySlots}
1377 onCreateAsset={createShotMemoryAsset}
1378 />
1379 ) : null;
1380 }
1381 : undefined
1382 }
1383 // framesSummary={framesSummary}
1384 batchGenerating={shotGenerationBusy}
1385 composeDisabled={shotGenerationBusy}
1386 referencesReady={workplace.references_ready === true}
1387 onGenerate={handleGenerateShot}
1388 onGenerateAll={handleGenerateAll}
1389 onUpdatePrompt={handleUpdatePrompt}
1390 onUpdateDuration={handleUpdateDuration}
1391 onRetry={handleRetryFrame}
1392 onAccept={handleAcceptFrame}
1393 onAcceptAll={handleAcceptAll}
1394 acceptAllBusy={acceptAllBusy}
1395 onRevise={handleReviseFrame}
1396 onSetContinuousMode={handleSetContinuousMode}
1397 busyFrameId={busyFrameId}
1398 onCompose={handleCompose}
1399 onPromptEditingChange={setAuxTextEditing}
1400 shot1FirstFrame={shot1FirstFrameControls}
1401 />
1402 );
1403 // case 3:
1404 // return (
1405 // <RenderOverlay
1406 // status="rendering"
1407 // progress={0}
1408 // onRetry={handleStartMerge}
1409 // onDismiss={() => {}}
1410 // hideBack={false}
1411 // />
1412 // );
1413 case 3:
1414 case 4:
1415 const renderStatus = composeRenderStatus(workplace);
1416 return (
1417 <>
1418 <ComposePanel
1419 renderStatus={renderStatus}
1420 composingTitle={
1421 workplace?.auto_generate && renderStatus === "rendering"
1422 ? "Automatic production in progress"
1423 : undefined
1424 }
1425 composingHint={
1426 workplace?.auto_generate && renderStatus === "rendering"
1427 ? "Generating and assembling all shots automatically"
1428 : undefined
1429 }
1430 error={composeRenderError(workplace)}
1431 videoUrl={workplace?.final_video?.url}
1432 sessionKey={sessionKey ?? undefined}
1433 downloadFileName={workplace?.final_video?.name}
1434 onRetry={handleComposeRetry}
1435 onNewChat={
1436 onNewChat
1437 ? () => {
1438 void onNewChat();
1439 }
1440 : undefined
1441 }
1442 playbackInOverlay={showOverlay && renderStatus === "done"}
1443 storyboardSegments={editor.segments.map(({ id, text }) => ({
1444 id,
1445 text,
1446 }))}
1447 echoRequestId={workplace?.echo_request_id}
1448 likeStatus={workplace?.like_status}
1449 onEchoLike={updateEchoLike}
1450 onEchoDownloadPrompt={recordEchoDownloadPrompt}
1451 />
1452 </>
1453 );
1454 default:
1455 return null;
1456 }
1457 }, [
1458 beatsApplyLabel,
1459 busyFrameId,
1460 editor,
1461 frames,
1462 memoryWorkspaceAssets,
1463 memoryWorkspaceBusy,
1464 generationBusy,
1465 handleAcceptFrame,
1466 handleConfirmStory,
1467 handleDeleteSegment,
1468 handleGenerateShot,
1469 handleSetContinuousMode,
1470 handleMergeUp,
1471 handleRetryFrame,
1472 handleReviseFrame,
1473 handleSplitAt,
1474 handleStoryboardNext,
1475 handleStoryboardAutoGenerate,
1476 handleGenerateAll,
1477 handleAcceptAll,
1478 handleCompose,
1479 handleComposeRetry,
1480 handleUpdatePrompt,
1481 handleUpdateDuration,
1482 onNewChat,
1483 showOverlay,
1484 acceptAllBusy,
1485 sessionKey,
1486 saveMemoryAsset,
1487 createShotMemoryAsset,
1488 deleteMemoryAsset,
1489 saveShotMemorySlots,
1490 shotGenerationBusy,
1491 shot1FirstFrameControls,
1492 showThinking,
1493 splitMergeBusy,
1494 step,
1495 storyApplyLabel,
1496 storyBusy,
1497 storyValue,
1498 storyWaitingForShots,
1499 workplace,
1500 updateEchoLike,
1501 recordEchoDownloadPrompt,
1502 ]);
1503 return (
1504 <section className="flex h-full min-h-0 flex-col bg-background">
1505 {sessionKey ? <WorkflowStageCard workplace={workplace} /> : null}
1506
1507 <div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/30 [&::-webkit-scrollbar-track]:bg-transparent">
1508 {!sessionKey ? (
1509 <div className="flex h-full min-h-[18rem] flex-col items-center justify-center rounded-3xl border border-dashed border-border/80 bg-muted/35 px-6 text-center">
1510 <p className="text-base font-medium text-foreground/88">
1511 Select a project
1512 </p>
1513 <p className="mt-2 text-sm leading-6 text-muted-foreground">
1514 The production workspace follows the active conversation.
1515 </p>
1516 </div>
1517 ) : null}
1518 {workflowContent}
1519
1520 {showOverlay && mergeRenderStatus !== "idle" ? (
1521 <RenderOverlay
1522 status={mergeRenderStatus}
1523 videoUrl={workplace?.final_video?.url}
1524 sessionKey={sessionKey ?? undefined}
1525 downloadFileName={workplace?.final_video?.name}
1526 onRetry={handleMergeOverlayRetry}
1527 onDismiss={handleDismissOverlay}
1528 onNewChat={
1529 onNewChat
1530 ? () => {
1531 void onNewChat();
1532 }
1533 : undefined
1534 }
1535 hideBack={false}
1536 echoRequestId={workplace?.echo_request_id}
1537 likeStatus={workplace?.like_status}
1538 onEchoLike={updateEchoLike}
1539 />
1540 ) : null}
1541
1542 {error ? (
1543 <p className="mt-4 text-xs text-destructive">{error}</p>
1544 ) : null}
1545 </div>
1546
1547 <StepConfirmDialog
1548 open={pendingConfirm !== null}
1549 to={pendingConfirm ? ACTION_TO_STEP[pendingConfirm] : 2}
1550 onConfirm={handleStepConfirm}
1551 onCancel={handleStepCancel}
1552 />
1553 <StepConfirmDialog
1554 open={shotCountAlertOpen}
1555 alert={{
1556 title: "Shot count required",
1557 desc: "Confirm the number of shots before continuing.",
1558 }}
1559 onConfirm={() => setShotCountAlertOpen(false)}
1560 onCancel={() => setShotCountAlertOpen(false)}
1561 />
1562 <StepConfirmDialog
1563 open={shotEditAlertOpen}
1564 alert={{
1565 title: "Unsaved changes",
1566 desc: "Save the current edit before continuing.",
1567 }}
1568 onConfirm={() => setShotEditAlertOpen(false)}
1569 onCancel={() => setShotEditAlertOpen(false)}
1570 />
1571 <StepConfirmDialog
1572 open={GENERATION_BUSY_ALERT_ENABLED && generationCongestedOpen}
1573 alert={{
1574 title: "Generation service busy",
1575 desc: "The generation service is busy. Please retry.",
1576 actionLabel: "Retry",
1577 }}
1578 onConfirm={handleGenerationCongestedRetry}
1579 onCancel={handleGenerationCongestedRetry}
1580 />
1581 <GenerationAlertDialog
1582 open={
1583 generationAlerts.activeVariant === "error" ||
1584 (GENERATION_BUSY_ALERT_ENABLED &&
1585 generationAlerts.activeVariant === "congested")
1586 }
1587 variant={generationAlerts.activeVariant ?? "error"}
1588 onDismiss={generationAlerts.dismissActive}
1589 />
1590 </section>
1591 );
1592 }
1593
1593 lines Plain Text