mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Fix blank screen crash and text selection loss in video prompt box (#1315)
## Summary - **Fix blank screen crash** during video recreate flow: typing text, adjusting time slider, typing again, then clicking the text box caused an uncaught DOM exception in MentionTextarea's contentEditable caret operations, which unmounted the entire React tree (no error boundaries existed) - **Fix text selection disappearing** on mouse release in MentionTextarea: click handler and sync useEffect were destroying active selections by rewriting innerHTML and restoring only a collapsed caret - **Add React Error Boundary** around PromptBoxVideo as a safety net ## Changes ### MentionTextarea.tsx - Wrap `getCaretOffset`, `setCaretOffset`, `scrollCaretIntoView`, `handleInput`, sync `useEffect`, and `getOffsetRect` in try/catch to prevent uncaught DOMExceptions - Add null guard for `childNodes` iteration and `parentNode` access - Skip `detectMention` in `handleClick` when user has active text selection - Skip innerHTML rewrite in sync useEffect when editor has selected text ### PromptBoxVideo.tsx - Stabilize `characterNames` with `useMemo` to prevent `mentionColorMap` from rebuilding every render - Move empty characters array to module-level constant for stable reference - Add cleanup `useEffect` for `durationTimerRef` to prevent stale timer after unmount ### New: PromptBoxErrorBoundary.tsx - React Error Boundary with "Try again" recovery button ### ImageToVideo.tsx - Wrap `<PromptBoxVideo>` with `<PromptBoxErrorBoundary>` ## Reproduction steps (original bug) 1. Click "Recreate" on a video 2. Type text into the prompt box 3. Adjust the time slider 4. Type text again 5. Adjust the time slider and click the text box → **UI goes blank** ## Test plan - [x] Reproduced blank screen bug on Mac before fix - [x] Verified fix: repeated recreation flow no longer causes blank screen - [x] Verified text selection persists after mouse release - [x] Video generation with @mentions, reference images, and duration changes works correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
51e5db6b12
commit
5fc27a5153
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { PromptBoxVideo, usePromptVideoStore } from "@storyteller/ui-promptbox";
|
||||
import { PromptBoxVideo, usePromptVideoStore, PromptBoxErrorBoundary } from "@storyteller/ui-promptbox";
|
||||
import { UploadImageMedia, UploadVideoMedia, UploadAudioMedia } from "@storyteller/api";
|
||||
import BackgroundGallery from "./BackgroundGallery";
|
||||
import {
|
||||
@@ -199,24 +199,26 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
</div>
|
||||
)} */}
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxVideo
|
||||
useJobContext={() => jobContext}
|
||||
selectedModel={selectedVideoModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
onImageRowVisibilityChange={setImageRowVisible}
|
||||
uploadImage={UploadImageMedia}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
credits={videoCredits}
|
||||
onEnqueuePressed={async (prompt, subscriberIds) => {
|
||||
const modelLabel = selectedVideoModel?.fullName ?? "";
|
||||
for (const subscriberId of subscriberIds) {
|
||||
startBatch(prompt, modelLabel, subscriberId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PromptBoxErrorBoundary>
|
||||
<PromptBoxVideo
|
||||
useJobContext={() => jobContext}
|
||||
selectedModel={selectedVideoModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
onImageRowVisibilityChange={setImageRowVisible}
|
||||
uploadImage={UploadImageMedia}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
credits={videoCredits}
|
||||
onEnqueuePressed={async (prompt, subscriberIds) => {
|
||||
const modelLabel = selectedVideoModel?.fullName ?? "";
|
||||
for (const subscriberId of subscriberIds) {
|
||||
startBatch(prompt, modelLabel, subscriberId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PromptBoxErrorBoundary>
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./lib/PromptBoxEdit";
|
||||
export * from "./lib/PromptBoxVideo";
|
||||
export * from "./lib/PromptBoxImage";
|
||||
export * from "./lib/promptStore";
|
||||
export * from "./lib/PromptBoxErrorBoundary";
|
||||
|
||||
@@ -55,99 +55,109 @@ function escapeHTML(text: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCaretOffset(el: HTMLElement): number {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorNode = sel.anchorNode;
|
||||
const anchorOffset = sel.anchorOffset;
|
||||
let offset = 0;
|
||||
|
||||
if (anchorNode === el) {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (!child) break;
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function countBefore(parent: Node): boolean {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child === anchorNode) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += anchorOffset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
countBefore(el);
|
||||
return offset;
|
||||
} catch (e) {
|
||||
console.debug("getCaretOffset: DOM/selection changed during read", e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorNode = sel.anchorNode;
|
||||
const anchorOffset = sel.anchorOffset;
|
||||
let offset = 0;
|
||||
|
||||
if (anchorNode === el) {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function countBefore(parent: Node): boolean {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child === anchorNode) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += anchorOffset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
countBefore(el);
|
||||
return offset;
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
let remaining = offset;
|
||||
try {
|
||||
let remaining = offset;
|
||||
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!findPosition(el)) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.selectAllChildren(el);
|
||||
sel.collapseToEnd();
|
||||
if (!findPosition(el)) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.selectAllChildren(el);
|
||||
sel.collapseToEnd();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("setCaretOffset: DOM changed during caret restore", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,39 +166,47 @@ function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
* Uses a zero-width space span to get a measurable rect on empty lines.
|
||||
*/
|
||||
function scrollCaretIntoView(el: HTMLElement) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200B";
|
||||
range.insertNode(span);
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200B";
|
||||
range.insertNode(span);
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
if (spanRect.bottom > elRect.bottom) {
|
||||
el.scrollTop += spanRect.bottom - elRect.bottom;
|
||||
} else if (spanRect.top < elRect.top) {
|
||||
el.scrollTop -= elRect.top - spanRect.top;
|
||||
if (spanRect.bottom > elRect.bottom) {
|
||||
el.scrollTop += spanRect.bottom - elRect.bottom;
|
||||
} else if (spanRect.top < elRect.top) {
|
||||
el.scrollTop -= elRect.top - spanRect.top;
|
||||
}
|
||||
|
||||
// Remove temp node and restore caret after it
|
||||
const parent = span.parentNode;
|
||||
if (parent) {
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else if (parent.lastChild) {
|
||||
restored.setStartAfter(parent.lastChild);
|
||||
} else {
|
||||
restored.selectNodeContents(parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("scrollCaretIntoView: DOM changed during measurement", e);
|
||||
}
|
||||
|
||||
// Remove temp node and restore caret after it
|
||||
const parent = span.parentNode!;
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else {
|
||||
restored.setStartAfter(parent.lastChild ?? parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -397,38 +415,52 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const caret = pendingCaret.current ?? getCaretOffset(el);
|
||||
el.innerHTML = buildHTML(value);
|
||||
if (pendingCaret.current !== null) {
|
||||
setCaretOffset(el, pendingCaret.current);
|
||||
pendingCaret.current = null;
|
||||
} else if (document.activeElement === el) {
|
||||
setCaretOffset(el, caret);
|
||||
// Don't rewrite innerHTML while the user has text selected
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement === el && sel && !sel.isCollapsed) return;
|
||||
|
||||
try {
|
||||
const caret = pendingCaret.current ?? getCaretOffset(el);
|
||||
el.innerHTML = buildHTML(value);
|
||||
if (pendingCaret.current !== null) {
|
||||
setCaretOffset(el, pendingCaret.current);
|
||||
pendingCaret.current = null;
|
||||
} else if (document.activeElement === el) {
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("MentionTextarea sync: DOM changed during caret restore", e);
|
||||
el.innerHTML = buildHTML(value);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
|
||||
// Get pixel coordinates of a text offset relative to the wrapper
|
||||
const getOffsetRect = useCallback((charOffset: number) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
try {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
// Temporarily place caret at charOffset to measure position
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount) {
|
||||
// Temporarily place caret at charOffset to measure position
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount) {
|
||||
setCaretOffset(el, saved);
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
};
|
||||
} catch (e) {
|
||||
console.debug("getOffsetRect: DOM changed during measurement", e);
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position
|
||||
@@ -466,26 +498,33 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let text = el.innerText;
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
try {
|
||||
let text = el.innerText;
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
const caret = getCaretOffset(el);
|
||||
const html = buildHTML(text);
|
||||
if (el.innerHTML !== html) {
|
||||
el.innerHTML = html;
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
|
||||
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug("handleInput: DOM changed during input processing", e);
|
||||
const text = el.innerText?.replace(/\n$/, "") ?? "";
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
}
|
||||
|
||||
const caret = getCaretOffset(el);
|
||||
const html = buildHTML(text);
|
||||
if (el.innerHTML !== html) {
|
||||
el.innerHTML = html;
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
|
||||
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
}, [onChange, buildHTML, detectMention]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
@@ -586,9 +625,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (el) {
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return; // preserve text selection
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}, [value, detectMention]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, Fragment } from "react";
|
||||
import type { ReactNode, ErrorInfo } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
resetKey: number;
|
||||
}
|
||||
|
||||
export class PromptBoxErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, resetKey: 0 };
|
||||
|
||||
static getDerivedStateFromError(): Partial<State> {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("PromptBox crashed:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-xl border border-red-500/30 bg-red-500/10 px-6 py-4 text-sm text-red-300">
|
||||
<span>Something went wrong with the prompt box. </span>
|
||||
<button
|
||||
className="underline hover:text-red-200"
|
||||
onClick={() =>
|
||||
this.setState((prev) => ({
|
||||
hasError: false,
|
||||
resetKey: prev.resetKey + 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment key={this.state.resetKey}>{this.props.children}</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
RefImage,
|
||||
VideoInputMode,
|
||||
useCharactersStore,
|
||||
StoredCharacter,
|
||||
} from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
@@ -58,6 +59,8 @@ declare global {
|
||||
|
||||
type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
|
||||
|
||||
const EMPTY_CHARACTERS: StoredCharacter[] = [];
|
||||
|
||||
const DEFAULT_RESOLUTIONS: SizeOption[] = [
|
||||
{
|
||||
tauriValue: "720p",
|
||||
@@ -335,7 +338,9 @@ export const PromptBoxVideo = ({
|
||||
const [localDuration, setLocalDuration] = useState(effectiveDuration);
|
||||
const durationTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
clearTimeout(durationTimerRef.current);
|
||||
setLocalDuration(effectiveDuration);
|
||||
return () => clearTimeout(durationTimerRef.current);
|
||||
}, [effectiveDuration]);
|
||||
const handleDurationSlide = (v: number) => {
|
||||
setLocalDuration(v);
|
||||
@@ -419,10 +424,13 @@ export const PromptBoxVideo = ({
|
||||
|
||||
// Characters are only supported for seedance_2p0
|
||||
const isSeedance2p0 = selectedModel?.id === "seedance_2p0";
|
||||
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
|
||||
const activeCharacters = isSeedance2p0 ? storedCharacters : EMPTY_CHARACTERS;
|
||||
|
||||
// Build a set of character names for highlight matching
|
||||
const characterNames = activeCharacters.map((c) => c.name);
|
||||
const characterNames = useMemo(
|
||||
() => activeCharacters.map((c) => c.name),
|
||||
[activeCharacters],
|
||||
);
|
||||
|
||||
const hasAnyMentionables = hasAnyRefs || activeCharacters.length > 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user