From 165b4443ab549af9eab32906d53d857888d9cf88 Mon Sep 17 00:00:00 2001 From: QAyong <131734913+QAyong@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:52:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(create):=20=E4=BF=AE=E5=A4=8D=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E7=94=9F=E6=88=90=E7=8A=B6=E6=80=81=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=AA=92=E4=BD=93=E9=A2=84=E8=A7=88=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/progress/pending-test.mdx | 5 + web/src/lib/user-session.ts | 5 +- web/src/pages/create/index.tsx | 201 +++++++++++++++--- .../stores/use-creation-preferences-store.ts | 96 +++++++++ web/src/styles/globals.css | 7 +- 5 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 web/src/stores/use-creation-preferences-store.ts diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a0f910f9c..e276dd564 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -326,6 +326,11 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [x] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。 - [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。 - [ ] 在触屏及窄窗口验证删除按钮无需悬停也可见,素材轨道可横向浏览且不会遮挡提示词和底部控制区。 +- [ ] 在创作首页选择图片或视频生成并修改画幅、质量、时长或生成数量后切换到其他工作区再返回,确认上次的创作类型和各自参数仍保留;切换到不支持某个已保存参数的模型时,确认该参数仅回退为当前模型默认值;生成结果点击“添加到画布”后确认仍能按现有交接流程进入画布。 + +## 首页生成图片预览交互 + +- [ ] 在生成结果、参考图片和故事板图片预览中使用鼠标滚轮放大/缩小,确认缩放以鼠标位置为中心;放大后按住鼠标左键拖动查看局部,缩回原尺寸后确认画面回到居中状态。 ## Agnes Video 2.5 JSON 协议 diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 91e403245..468f86ea9 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -7,6 +7,7 @@ import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@ import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"; import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; +import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { useUserStore } from "@/stores/use-user-store"; import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store"; @@ -38,17 +39,19 @@ export async function applyUserSession(payload: AuthSessionPayload) { localForageStorage.getItem(PLUGIN_STORE_KEY), ]); const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY); + const persistedCreationPreferences = scopedLocalStorage.getItem(CREATION_PREFERENCES_STORE_KEY); usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} }); useUserStore.getState().setUser(payload.user); useUserStore.getState().setRuntimeLimits(payload.runtimeLimits); useUserStore.getState().setDrawingEngine(payload.drawingEngine); useUserStore.getState().setFeatures(payload.features); - await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]); + await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate(), useCreationPreferencesStore.persist.rehydrate()]); // Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。 if (!persistedCanvas) useCanvasStore.setState({ projects: [] }); if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] }); if (!persistedAssets) useAssetStore.setState({ assets: [] }); if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} }); + if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} }); if (!persistedConfig) { // 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。 // 使用统一模型目录接口 diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 0278d784c..f4917a60c 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -36,9 +36,10 @@ import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; -import { loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; +import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { recoverCreationTextTask } from "@/services/creation-text-task-recovery"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useAppearanceStore } from "@/stores/use-appearance-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -138,6 +139,11 @@ export default function CreatePage() { const { message: toast, modal } = App.useApp(); const brandName = useAppearanceStore((state) => state.appearance.brandName); const config = useEffectiveConfig(); + const composerPreferencesHydrated = useCreationPreferencesStore((state) => state.hydrated); + const rememberMode = useCreationPreferencesStore((state) => state.rememberMode); + const rememberImageSettings = useCreationPreferencesStore((state) => state.rememberImageSettings); + const rememberVideoSettings = useCreationPreferencesStore((state) => state.rememberVideoSettings); + const initialComposerPreferences = useCreationPreferencesStore.getState().preferences; const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); const promptOptimizerProvider = useMemo(() => { @@ -152,7 +158,7 @@ export default function CreatePage() { const [activeId, setActiveId] = useState(""); const activeIdRef = useRef(""); const [hydrated, setHydrated] = useState(false); - const [mode, setMode] = useState("video"); + const [mode, setMode] = useState(() => initialComposerPreferences.mode || "video"); const [prompt, setPrompt] = useState(""); const [attachments, setAttachments] = useState([]); const promptRef = useRef(prompt); @@ -177,9 +183,11 @@ export default function CreatePage() { const threadScrollRef = useRef(null); const followLatestMessageRef = useRef(true); const taskSyncWarningRef = useRef(false); + const activeGenerationTaskIdsRef = useRef(new Set()); const retryPreparingRef = useRef(new Set()); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const [retrySequence, setRetrySequence] = useState(0); + const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false); promptRef.current = prompt; attachmentsRef.current = attachments; @@ -220,38 +228,58 @@ export default function CreatePage() { }, [attachments]); const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; - const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]); const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); + const recoveryTaskKey = useMemo(() => pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)).join("|"), [pendingTaskIds]); const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]); const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1; useEffect(() => { - if (mode !== "image") return; - // 前台逻辑模型的默认参数优先于旧的全局创作参数;否则旧的合法值会一直覆盖后台刚配置的默认值。 + if (!composerPreferencesHydrated || composerPreferencesInitialized) return; + const saved = useCreationPreferencesStore.getState().preferences; + const nextMode = saved.mode || "video"; + setMode(nextMode); + if (nextMode === "image" && saved.image) { + if (saved.image.ratio) setRatio(saved.image.ratio); + if (saved.image.quality) setQuality(saved.image.quality); + if (saved.image.count) setCount(saved.image.count); + } + if (nextMode === "video" && saved.video) { + if (saved.video.ratio) setRatio(saved.video.ratio); + if (saved.video.seconds) setSeconds(saved.video.seconds); + if (saved.video.videoQuality) setVideoQuality(saved.video.videoQuality); + } + setComposerPreferencesInitialized(true); + }, [composerPreferencesHydrated, composerPreferencesInitialized]); + + useEffect(() => { + if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "image") return; + const saved = useCreationPreferencesStore.getState().preferences.image; + // 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeImageValue 才回退到模型默认值。 const normalized = normalizeImageValue(imageProfile, { - size: imageProfile.size.default, - quality: imageProfile.quality.default, - count, + size: saved?.ratio || imageProfile.size.default, + quality: saved?.quality || imageProfile.quality.default, + count: saved?.count || count, }); setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); - }, [mode, selectedModel, imageProfile]); + }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, imageProfile]); useEffect(() => { - if (mode !== "video") return; - // 前台逻辑模型的默认参数必须直接落到创作端状态,提交任务时才不会被旧状态覆盖。 + if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "video") return; + const saved = useCreationPreferencesStore.getState().preferences.video; + // 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeVideoValue 才回退到模型默认值。 const normalized = normalizeVideoValue(videoProfile, { - seconds: String(videoProfile.duration.default), - ratio: videoProfile.defaultRatio, - resolution: videoProfile.defaultResolution, + seconds: saved?.seconds || String(videoProfile.duration.default), + ratio: saved?.ratio || videoProfile.defaultRatio, + resolution: saved?.videoQuality || videoProfile.defaultResolution, }); setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0; if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences)); - }, [mode, selectedModel, videoProfile]); + }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, videoProfile]); useEffect(() => { const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); @@ -288,7 +316,10 @@ export default function CreatePage() { }, [conversations, hydrated]); useEffect(() => { - if (!hydrated || !pendingTaskKey || !pendingTaskIds.length) return; + if (!hydrated || !recoveryTaskKey || !pendingTaskIds.length) return; + // 当前页面主动提交的任务由 submit 自己等待并收尾;恢复监听只接管刷新前遗留的任务,避免同一任务被双重轮询。 + const recoverableTaskIds = pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)); + if (!recoverableTaskIds.length) return; let cancelled = false; const observationController = new AbortController(); const applyTasks = async (tasks: GenerationTask[]) => { @@ -298,14 +329,19 @@ export default function CreatePage() { taskSyncWarningRef.current = false; const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length)); for (const task of attachable) { - await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => { - if (cancelled) return; - await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) => - applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value, - ); - }, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls }); + try { + await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => { + if (cancelled) return; + await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) => + applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value, + ); + }, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls }); + } catch (error) { + if (cancelled || observationController.signal.aborted) return; + console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error); + } } - if (!attachable.length && !cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks)); + if (!cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks)); }; const warnSync = (error: unknown) => { if (cancelled || observationController.signal.aborted) return; @@ -316,7 +352,7 @@ export default function CreatePage() { } }; let applyChain = Promise.resolve(); - const unsubscribe = subscribeGenerationTasks(pendingTaskIds, (task) => { + const unsubscribe = subscribeGenerationTasks(recoverableTaskIds, (task) => { applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync); }); return () => { @@ -324,7 +360,7 @@ export default function CreatePage() { observationController.abort(); unsubscribe(); }; - }, [hydrated, pendingTaskKey, toast]); + }, [hydrated, recoveryTaskKey, toast]); useEffect(() => { let cancelled = false; @@ -366,6 +402,7 @@ export default function CreatePage() { const selectMode = (next: CreationMode) => { setMode(next); + rememberMode(next); const nextModels = selectableModelsByCapability(config, next); const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel; if (!nextModels.includes(current) && nextModels[0]) { @@ -373,6 +410,28 @@ export default function CreatePage() { } }; + const setComposerRatio = (value: string) => { + setRatio(value); + if (mode === "image") rememberImageSettings({ ratio: value }); + if (mode === "video") rememberVideoSettings({ ratio: value }); + }; + const setComposerSeconds = (value: string) => { + setSeconds(value); + if (mode === "video") rememberVideoSettings({ seconds: value }); + }; + const setComposerQuality = (value: string) => { + setQuality(value); + if (mode === "image") rememberImageSettings({ quality: value }); + }; + const setComposerVideoQuality = (value: string) => { + setVideoQuality(value); + if (mode === "video") rememberVideoSettings({ videoQuality: value }); + }; + const setComposerCount = (value: string) => { + setCount(value); + if (mode === "image") rememberImageSettings({ count: value }); + }; + const externalLibraryItems = useMemo( () => externalAssetSources.items.map((item) => ({ ...item, @@ -570,6 +629,7 @@ export default function CreatePage() { const bindTask = (task: GenerationTask) => { if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id); boundTaskIds.add(task.id); + activeGenerationTaskIdsRef.current.add(task.id); boundTasks.set(task.id, task); updateOriginAssistant((item) => ({ ...item, generationStage: task.stage, generationOperation: task.operation, generationErrorCode: task.errorCode, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])), clientOperationId: task.clientOperationId, retryOf: task.retryOf, attemptGroupId: task.attemptGroupId })); if (abortRef.current === controller) { @@ -699,6 +759,7 @@ export default function CreatePage() { const message = generationErrorMessage(error); updateOriginAssistant((item) => ({ ...item, status: "error", error: message, generationErrorCode: item.generationErrorCode || generationErrorCode(error), generationOperation: item.generationOperation || (mode === "video" ? videoOperation : mode), createdAt: assistantMessage.createdAt, content: "生成失败" })); } finally { + for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId); requestLifecycle.release(); releaseRetryLock(); if (abortRef.current === controller) { @@ -781,7 +842,7 @@ export default function CreatePage() { const restoreMessageDraft = (item: CreationMessage) => { const nextMode = item.mode || "text"; const nextSettings = item.settings; - setMode(nextMode); + selectMode(nextMode); setPrompt(item.content); setAttachments(item.attachments ? [...item.attachments] : []); setDraftReferences(item.references ? [...item.references] : []); @@ -792,6 +853,8 @@ export default function CreatePage() { setQuality(nextSettings.quality); setVideoQuality(nextSettings.videoQuality); setCount(nextSettings.count); + if (nextMode === "image") rememberImageSettings({ ratio: nextSettings.ratio, quality: nextSettings.quality, count: nextSettings.count }); + if (nextMode === "video") rememberVideoSettings({ ratio: nextSettings.ratio, seconds: nextSettings.seconds, videoQuality: nextSettings.videoQuality }); }; const retryFailedMessage = async (item: CreationMessage, index: number) => { @@ -876,15 +939,15 @@ export default function CreatePage() { config, onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value), ratio, - setRatio, + setRatio: setComposerRatio, seconds, - setSeconds, + setSeconds: setComposerSeconds, quality, - setQuality, + setQuality: setComposerQuality, videoQuality, - setVideoQuality, + setVideoQuality: setComposerVideoQuality, count, - setCount, + setCount: setComposerCount, promptOptimizerProvider, composerFocusRef, placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined, @@ -1101,8 +1164,82 @@ function CreationMessageReferences({ references }: { references: CreationReferen })}; } +type CreationImagePreviewView = { scale: number; offsetX: number; offsetY: number }; + +const initialCreationImagePreviewView: CreationImagePreviewView = { scale: 1, offsetX: 0, offsetY: 0 }; + +function CreationImagePreview({ url }: { url: string }) { + const viewportRef = useRef(null); + const imageRef = useRef(null); + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number } | null>(null); + const [view, setView] = useState(initialCreationImagePreviewView); + const [dragging, setDragging] = useState(false); + + useEffect(() => { + setView(initialCreationImagePreviewView); + dragRef.current = null; + setDragging(false); + }, [url]); + + const clampOffset = (offsetX: number, offsetY: number, scale: number) => { + const viewport = viewportRef.current; + const image = imageRef.current; + if (!viewport || !image) return { offsetX, offsetY }; + const maxOffsetX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2); + const maxOffsetY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2); + return { + offsetX: Math.max(-maxOffsetX, Math.min(maxOffsetX, offsetX)), + offsetY: Math.max(-maxOffsetY, Math.min(maxOffsetY, offsetY)), + }; + }; + + const handleWheel = (event: React.WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + const viewport = event.currentTarget; + const rect = viewport.getBoundingClientRect(); + const pointerX = event.clientX - (rect.left + rect.width / 2); + const pointerY = event.clientY - (rect.top + rect.height / 2); + setView((current) => { + const nextScale = Math.max(1, Math.min(5, current.scale * Math.exp(-event.deltaY * 0.0015))); + if (nextScale === current.scale) return current; + if (nextScale === 1) return initialCreationImagePreviewView; + const scaleRatio = nextScale / current.scale; + const nextOffset = clampOffset(pointerX + (current.offsetX - pointerX) * scaleRatio, pointerY + (current.offsetY - pointerY) * scaleRatio, nextScale); + return { scale: nextScale, ...nextOffset }; + }); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (view.scale <= 1 || event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffsetX: view.offsetX, startOffsetY: view.offsetY }; + setDragging(true); + }; + + const handlePointerMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + event.preventDefault(); + const nextOffset = clampOffset(drag.startOffsetX + event.clientX - drag.startX, drag.startOffsetY + event.clientY - drag.startY, view.scale); + setView((current) => ({ ...current, ...nextOffset })); + }; + + const endDrag = (event: PointerEvent) => { + if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + }; + + return
1 ? " is-zoomed" : ""}${dragging ? " is-dragging" : ""}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={endDrag}> + 媒体预览 + +
; +} + function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { - return {url ? type === "video" ? ; + return {url ? type === "video" ? ; } function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { diff --git a/web/src/stores/use-creation-preferences-store.ts b/web/src/stores/use-creation-preferences-store.ts new file mode 100644 index 000000000..b92bfb890 --- /dev/null +++ b/web/src/stores/use-creation-preferences-store.ts @@ -0,0 +1,96 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { scopedLocalStorage } from "@/lib/user-scope"; + +export type CreationModePreference = "text" | "image" | "video"; + +export type CreationImagePreferences = { + ratio?: string; + quality?: string; + count?: string; +}; + +export type CreationVideoPreferences = { + ratio?: string; + seconds?: string; + videoQuality?: string; +}; + +export type CreationComposerPreferences = { + mode?: CreationModePreference; + image?: CreationImagePreferences; + video?: CreationVideoPreferences; +}; + +type CreationPreferencesStore = { + hydrated: boolean; + preferences: CreationComposerPreferences; + rememberMode: (mode: CreationModePreference) => void; + rememberImageSettings: (settings: CreationImagePreferences) => void; + rememberVideoSettings: (settings: CreationVideoPreferences) => void; +}; + +export const CREATION_PREFERENCES_STORE_KEY = "open_ai_canvas:creation_preferences"; + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && Boolean(value.trim()); +} + +function normalizeImagePreferences(value: unknown): CreationImagePreferences | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const preferences = { + ...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}), + ...(nonEmptyString(raw.quality) ? { quality: raw.quality } : {}), + ...(nonEmptyString(raw.count) ? { count: raw.count } : {}), + }; + return Object.keys(preferences).length ? preferences : undefined; +} + +function normalizeVideoPreferences(value: unknown): CreationVideoPreferences | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const preferences = { + ...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}), + ...(nonEmptyString(raw.seconds) ? { seconds: raw.seconds } : {}), + ...(nonEmptyString(raw.videoQuality) ? { videoQuality: raw.videoQuality } : {}), + }; + return Object.keys(preferences).length ? preferences : undefined; +} + +export function normalizeCreationComposerPreferences(value: unknown): CreationComposerPreferences { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const raw = value as Record; + const image = normalizeImagePreferences(raw.image); + const video = normalizeVideoPreferences(raw.video); + return { + ...(raw.mode === "text" || raw.mode === "image" || raw.mode === "video" ? { mode: raw.mode } : {}), + ...(image ? { image } : {}), + ...(video ? { video } : {}), + }; +} + +export const useCreationPreferencesStore = create()( + persist( + (set) => ({ + hydrated: false, + preferences: {}, + rememberMode: (mode) => set((state) => ({ preferences: { ...state.preferences, mode } })), + rememberImageSettings: (settings) => set((state) => ({ preferences: { ...state.preferences, image: { ...state.preferences.image, ...settings } } })), + rememberVideoSettings: (settings) => set((state) => ({ preferences: { ...state.preferences, video: { ...state.preferences.video, ...settings } } })), + }), + { + name: CREATION_PREFERENCES_STORE_KEY, + storage: createJSONStorage(() => scopedLocalStorage), + partialize: (state) => ({ preferences: state.preferences }), + merge: (persisted, current) => { + const stored = (persisted || {}) as Partial; + return { ...current, preferences: normalizeCreationComposerPreferences(stored.preferences) }; + }, + onRehydrateStorage: () => () => { + useCreationPreferencesStore.setState({ hydrated: true }); + }, + }, + ), +); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 22cb17dc3..acb496e5e 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -11107,7 +11107,12 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .creation-media-preview-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; } .creation-media-preview-modal .ant-modal-close { color: #fff; } -.creation-media-preview-image, .creation-media-preview-video { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; } +.creation-media-preview-viewport { position: relative; display: flex; width: 100%; max-height: 82vh; align-items: center; justify-content: center; overflow: hidden; overscroll-behavior: contain; touch-action: none; user-select: none; } +.creation-media-preview-image { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; transform-origin: center center; user-select: none; will-change: transform; } +.creation-media-preview-viewport.is-zoomed .creation-media-preview-image { cursor: grab; } +.creation-media-preview-viewport.is-dragging .creation-media-preview-image { cursor: grabbing; } +.creation-media-preview-hint { position: absolute; right: 12px; bottom: 10px; pointer-events: none; border-radius: 999px; background: rgb(0 0 0 / 52%); color: rgb(255 255 255 / 78%); font-size: 11px; line-height: 1; padding: 7px 9px; } +.creation-media-preview-video { display: block; width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; } .creation-media-preview-video { width: 100%; background: #050505; } html:not(.dark) .creation-user-message { background: rgba(28, 32, 37, .07); }