feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池 (#708)
* feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池 创作首页空态换成直线纵深图片画廊:11 张横版卡片沿 Z 轴前后排队,当前一张 在最前最大,滚轮、左右按钮或自动轮播切换,卡片带玻璃描边和"行动点 / 标题 / 描述"三段信息,跟随输入框的创作类型联动换队。队列是随机取样并记住最近出现过的 条目,走完一轮自动换批,不再反复看到同样那几张。 左侧「创作」下方新增灵感页:1341 条提示词按来源、分类和创作类型筛选, 支持搜索、收藏,以及自建灵感的新建、编辑、删除和 JSON 导入导出。 新增 scripts/import-inspirations.mjs,从 awesome-seedance、好好学 AI 和 YouMind 两个姊妹库同步条目,并为画廊池生成本地 webp 缩略图(560px / q72, 约 35KB 一张)。上游封面平均 400KB~700KB,直接引用一屏 11 张要拉 5MB 以上, 换成本地缩略图后约 400KB。 画廊池改走 public/inspirations/gallery-pool.json 运行时拉取:几百条提示词正文 压在首屏 JS 上是白背的,而这份数据只服务画廊一处。缩略图等距抽取,横跨各来源 的全部分类;只收描述非空的条目,保证卡片三行版式不缺角。 * docs(create): 功能清单与地图 - 补充纵深画廊与灵感库 功能清单新增「创作首页纵深画廊与灵感库」一节,并在功能地图里记下画廊、 灵感页、运行时目录加载和导入脚本各自的代码入口;待测试清单记下浏览器验收结果 与仍未确认的边界(弱网退回、窄屏、文本条目偏少、外部来源署名)。
This commit is contained in:
@@ -17,7 +17,9 @@ export type CreationDocumentAttachment = {
|
||||
previewUrl: string;
|
||||
};
|
||||
export type CreationAttachment = ((ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }) | CreationDocumentAttachment;
|
||||
export type CreationMode = "text" | "image" | "video";
|
||||
import type { CreationMode } from "@/lib/creation-mode";
|
||||
|
||||
export type { CreationMode };
|
||||
export type CreationAttachmentKind = "image" | "video" | "audio" | "file";
|
||||
|
||||
const textDocumentExtensions = [".pdf", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"];
|
||||
|
||||
388
web/src/pages/create/creation-inspiration-tunnel.css
Normal file
388
web/src/pages/create/creation-inspiration-tunnel.css
Normal file
@@ -0,0 +1,388 @@
|
||||
/* 创作页空态的纵深图片画廊。
|
||||
卡片沿 Z 轴排队:当前一张在最前、正对画面且最大,其余按层往后推,
|
||||
每层按等比缩小并向中轴收拢,形成展厅走廊那种一排挂在纵深上的画。
|
||||
不做弧形——弯曲会立刻丢掉"纵深"的错觉。
|
||||
|
||||
缩放在 JS 里按 perspective 公式预先算好,而不是交给 CSS perspective:
|
||||
平面卡片在固定 z 上做投影,结果就是等比缩放加平移,自己算省掉每帧的投影光栅化。
|
||||
|
||||
滚轮或左右按钮切换,切换靠 transform / opacity 的 transition 平滑过渡。 */
|
||||
|
||||
/* 内容层压住画廊,但指针事件要能穿过空白处,否则整条画廊都点不到;
|
||||
真正的交互元素由下面一条规则恢复命中。 */
|
||||
.creation-home .creation-empty-workspace {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creation-home .creation-empty-workspace > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 画廊占中上部、输入框沉到下方:
|
||||
先把 main 的居中改掉,否则 justify-content: center 会吃掉 margin-top: auto。 */
|
||||
.creation-home .creation-empty-workspace {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.creation-home .creation-home-heading {
|
||||
margin-top: clamp(16px, 5vh, 52px);
|
||||
}
|
||||
|
||||
.creation-home .creation-empty-workspace > .creation-launchpad {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: clamp(16px, 5vh, 52px);
|
||||
width: min(960px, calc(100% - 32px));
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* ── 让画廊从内容区背后穿过 ──
|
||||
输入框、模式切换器、快捷卡换成毛玻璃:背后的照片透过来变成模糊底纹,
|
||||
文字仍落在半透明实色层上保持可读。 */
|
||||
.creation-home .creation-chat-composer {
|
||||
background: color-mix(in srgb, var(--user-surface) 70%, transparent) !important;
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
|
||||
.creation-home .creation-mode-tabs {
|
||||
background: color-mix(in srgb, var(--user-surface-muted) 72%, transparent) !important;
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
}
|
||||
|
||||
.creation-home .creation-empty-suggest .suggest-card {
|
||||
background: color-mix(in srgb, var(--creation-surface) 74%, transparent);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.25);
|
||||
backdrop-filter: blur(16px) saturate(1.25);
|
||||
}
|
||||
|
||||
/* 标题没有玻璃层可依附,用背景色光晕保住可读性,而不是整块盖住画面。 */
|
||||
.creation-home .creation-home-heading h1 {
|
||||
text-shadow:
|
||||
0 2px 20px var(--creation-bg, var(--workspace-page)),
|
||||
0 0 56px var(--creation-bg, var(--workspace-page));
|
||||
}
|
||||
|
||||
.creation-home .creation-home-heading p {
|
||||
text-shadow: 0 1px 14px var(--creation-bg, var(--workspace-page));
|
||||
}
|
||||
|
||||
/* ── 画廊本体 ──
|
||||
上下边界同时定义在这里,两侧切换按钮靠它们算出纵向居中位置。 */
|
||||
.creation-inspiration-tunnel {
|
||||
/* 上边让开标题,下边让开输入框——前卡刚好停在输入框上沿之上。 */
|
||||
--gallery-top: clamp(120px, 14vh, 184px);
|
||||
--gallery-bottom: clamp(430px, 48vh, 550px);
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creation-gallery-stage {
|
||||
position: absolute;
|
||||
top: var(--gallery-top);
|
||||
right: 0;
|
||||
bottom: var(--gallery-bottom);
|
||||
left: 0;
|
||||
/* 只有舞台能收到滚轮,避免整页滚动被劫持。 */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(440px, 40vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
/* 玻璃质感边框:亮描边勾出面板边缘,内侧一圈高光给厚度,
|
||||
外圈大而软的投影把面板从背景里"抬"起来,避免看成扁平纸片。
|
||||
阴影只留三层——11 张卡片都在动,每多一层就是每帧多一次大范围重绘。 */
|
||||
border: 1px solid rgba(255, 255, 255, .5);
|
||||
border-radius: var(--r-2xl, 16px);
|
||||
background: var(--creation-surface-raised, #f0f1ee);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, .55),
|
||||
0 0 0 1px rgba(0, 0, 0, .07),
|
||||
0 28px 54px -26px rgba(0, 0, 0, .52);
|
||||
cursor: pointer;
|
||||
/* 只有最前面那张能点,后面的点击会穿到页面上,不去抢焦点。 */
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
/* 封面就绪前整张卡不可见。先露面板再填图,会出现"右边有图、左边还是空白面板"的观感;
|
||||
--card-mount 由 .is-ready 翻开,opacity 走 transition 平滑淡入。 */
|
||||
opacity: calc(var(--card-mount, 0) * var(--card-depth-opacity, 1));
|
||||
/* 先把卡片按自身尺寸对中,再叠加纵深位移与缩放——位移不会被 scale 影响。
|
||||
--card-enter-y / --card-enter-scale 是入场位移,封面就绪后归零,
|
||||
所以卡片是"从略下方、略小的位置浮上来",而不是硬闪出来。 */
|
||||
transform:
|
||||
translate(-50%, -50%)
|
||||
translate(var(--card-x, 0px), var(--card-enter-y, 44px))
|
||||
scale(calc(var(--card-scale, 1) * var(--card-enter-scale, .95)));
|
||||
/* 只动 transform 和 opacity,两者都能交给合成层,不触发重排重绘。
|
||||
缓动先缓后快再长尾软着陆,观感是镜头平稳推拉而不是"弹"过去。 */
|
||||
transition:
|
||||
transform 660ms cubic-bezier(.32, .72, 0, 1),
|
||||
opacity 520ms cubic-bezier(.4, 0, .2, 1);
|
||||
}
|
||||
|
||||
/* 封面已就绪:放行这张卡。 */
|
||||
.creation-tunnel-card.is-ready {
|
||||
--card-mount: 1;
|
||||
--card-enter-y: 0px;
|
||||
--card-enter-scale: 1;
|
||||
}
|
||||
|
||||
.creation-tunnel-card.is-active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 悬停时抬起放大一点,给"可以点进去"的反馈。
|
||||
整条 transform 链重写一遍而不是用 translate / scale 独立属性:
|
||||
独立属性会插在 transform 之前合成,卡片会被缩放带得偏离原位。
|
||||
抬起用更短的时长——切换是运镜,悬停是手感,两者不该共用一个节奏。 */
|
||||
.creation-tunnel-card.is-active:hover {
|
||||
transform:
|
||||
translate(-50%, -50%)
|
||||
translate(var(--card-x, 0px), calc(var(--card-enter-y, 0px) - 14px))
|
||||
scale(calc(var(--card-scale, 1) * var(--card-enter-scale, 1) * 1.04));
|
||||
transition:
|
||||
transform 240ms cubic-bezier(.2, .8, .3, 1),
|
||||
opacity 520ms cubic-bezier(.4, 0, .2, 1);
|
||||
}
|
||||
|
||||
/* 玻璃罩:斜向高光 + 底部压暗,让面板读起来是一块有厚度的玻璃,不是一张图。 */
|
||||
.creation-tunnel-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(158deg, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0) 40%),
|
||||
linear-gradient(0deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, 0) 44%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creation-tunnel-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── 卡片上的三段提示词信息:行动点 / 标题 / 描述 ── */
|
||||
.creation-tunnel-card-info {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1, 4px);
|
||||
padding: 44px var(--space-4, 16px) var(--space-3, 12px);
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, .52) 40%, rgba(0, 0, 0, .84));
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, .55);
|
||||
opacity: 0;
|
||||
/* 比位移稍慢一点收尾,避免文字跟着画面一起"闪"进闪出。 */
|
||||
transition: opacity 380ms cubic-bezier(.4, 0, .2, 1);
|
||||
}
|
||||
|
||||
.creation-tunnel-card.is-active .creation-tunnel-card-info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 4px);
|
||||
font-size: var(--fs-label, 11px);
|
||||
line-height: 1.2;
|
||||
opacity: .84;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-cta svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-title {
|
||||
font-size: var(--fs-body, 14px);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: var(--fs-caption, 12px);
|
||||
line-height: 1.45;
|
||||
opacity: .84;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* ── 左右切换按钮 ── */
|
||||
.creation-gallery-arrow {
|
||||
position: absolute;
|
||||
/* 对齐画廊那条带子的纵向中线,而不是整屏中线——输入框在下面,整屏中线偏低。 */
|
||||
top: calc((100% - var(--gallery-top) - var(--gallery-bottom)) / 2 + var(--gallery-top));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 0;
|
||||
color: var(--user-ink, #fff);
|
||||
background: color-mix(in srgb, var(--user-surface) 62%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--user-ink) 14%, transparent);
|
||||
border-radius: var(--r-full, 999px);
|
||||
box-shadow: 0 14px 30px -14px rgba(0, 0, 0, .55);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
/* 用整条 transform 表达"居中 + 悬停放大":
|
||||
换成 scale 独立属性会插在 transform 之前合成,按钮会跟着往下掉。 */
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
transform var(--user-motion, 180ms) ease,
|
||||
background-color var(--user-motion, 180ms) ease,
|
||||
color var(--user-motion, 180ms) ease;
|
||||
}
|
||||
|
||||
.creation-gallery-arrow.is-prev {
|
||||
left: clamp(12px, 2.4vw, 40px);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow.is-next {
|
||||
right: clamp(12px, 2.4vw, 40px);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:hover {
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:focus-visible {
|
||||
outline: 2px solid var(--creation-accent, var(--user-accent));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.creation-gallery-arrow-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.creation-gallery-arrow svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 卡片对辅助技术隐藏,切换结果改由这条文本播报。 */
|
||||
.creation-gallery-announce {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 素材来源署名:CC BY 4.0 要求可追溯,画廊形态下不再有灵感分区页脚。 */
|
||||
.creation-inspiration-credit {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 14px;
|
||||
z-index: 2;
|
||||
max-width: 62ch;
|
||||
color: var(--creation-faint, var(--user-ink-soft));
|
||||
font-size: var(--fs-caption, 12px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.creation-inspiration-credit summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
opacity: .72;
|
||||
transition: opacity var(--user-motion, 180ms) ease;
|
||||
}
|
||||
|
||||
.creation-inspiration-credit summary:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.creation-inspiration-credit summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.creation-inspiration-credit p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.creation-inspiration-credit a {
|
||||
color: var(--creation-accent, var(--user-accent));
|
||||
}
|
||||
|
||||
/* 减少动效:不做入场位移、悬停也不放大,切换改为瞬时。
|
||||
(卡片仍然等封面就绪,只是不再有过渡过程。) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.creation-tunnel-card {
|
||||
--card-enter-y: 0px;
|
||||
--card-enter-scale: 1;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.creation-tunnel-card.is-active:hover {
|
||||
transform:
|
||||
translate(-50%, -50%)
|
||||
translate(var(--card-x, 0px), 0)
|
||||
scale(var(--card-scale, 1));
|
||||
}
|
||||
|
||||
.creation-gallery-arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:hover {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 窄屏:横向偏移收小一点,免得侧翼卡片被裁掉。 */
|
||||
@media (max-width: 1100px) {
|
||||
.creation-inspiration-tunnel {
|
||||
--gallery-top: clamp(72px, 11vh, 132px);
|
||||
--gallery-bottom: clamp(210px, 33vh, 320px);
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
width: min(400px, 48vw);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.creation-inspiration-credit {
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
width: 68vw;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-desc {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
}
|
||||
284
web/src/pages/create/creation-inspiration-tunnel.tsx
Normal file
284
web/src/pages/create/creation-inspiration-tunnel.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
// 创作页空态的纵深图片画廊:卡片沿 Z 轴前后排队,当前一张在最前、最大,
|
||||
// 其余依次后退缩小,像展厅里一排挂在纵深上的画。
|
||||
// 滚轮、左右按钮或自动轮播切换,切换靠 CSS transition 平滑过渡。
|
||||
// 卡片是带玻璃质感边框的实体面板,不做弧形、也不压成薄片。
|
||||
// 展示内容跟随下方输入框的创作类型:选视频就只排队视频灵感,选图片就只排队图片灵感。
|
||||
//
|
||||
// 队列是「随机取样」而不是固定切片:每次进场、每次换创作类型都会重新抽一批,
|
||||
// 并且记住最近出现过哪些,走完一轮自动换下一批,避免反复看到同样那几张。
|
||||
//
|
||||
// 池子不打包,走 public/inspirations/gallery-pool.json:几百条连提示词正文一起
|
||||
// 就是几百 KB,压进首屏 JS 是白背的,而这份数据只服务这一处。
|
||||
//
|
||||
// 每张卡片等自己的封面加载完再露面。不这么做的话,先到的图先显示、
|
||||
// 慢的那几张只剩一块空面板,观感就是"右边出来了左边还空着"。
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArrowUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
|
||||
import { catalogIdOf, type CreationInspiration } from "@/lib/inspirations/catalog";
|
||||
import { loadGalleryPool } from "@/services/inspiration-catalog";
|
||||
import type { CreationMode } from "./creation-types";
|
||||
import "./creation-inspiration-tunnel.css";
|
||||
|
||||
/** 画廊里排队展示的照片数,取奇数,两侧层数才对得齐。 */
|
||||
const GALLERY_SIZE = 11;
|
||||
/** 每后退一层的横向偏移(px)。 */
|
||||
const STEP_X = 185;
|
||||
/** 每后退一层的纵深(px)。 */
|
||||
const STEP_Z = 150;
|
||||
/** 虚拟焦距,只用来算出"后退一层缩多少",不再是 CSS perspective。 */
|
||||
const PERSPECTIVE = 1200;
|
||||
/** 最多同时可见的层数,再远就隐掉。 */
|
||||
const MAX_DEPTH = 4;
|
||||
/** 每一层的透明度,越靠后越淡,做出空气透视。 */
|
||||
const DEPTH_OPACITY = [1, 0.98, 0.95, 0.91, 0.86];
|
||||
/** 自动轮播间隔。悬停、聚焦或系统开了"减少动效"时暂停。 */
|
||||
const AUTO_ADVANCE_MS = 6500;
|
||||
/** 记住最近展示过的条目数,换页/刷新时不会又抽到同一批。 */
|
||||
const RECENT_MEMORY = 66;
|
||||
const RECENT_KEY = "yingce:creation-gallery-recent";
|
||||
/**
|
||||
* 等封面的兜底时长。正常情况下封面几百毫秒就回来了,这条不会生效;
|
||||
* 万一某张请求挂住不回,也不能让队列永远缺一块,超时就先放行。
|
||||
*/
|
||||
const COVER_FALLBACK_MS = 8000;
|
||||
|
||||
/**
|
||||
* 把"离眼睛多远"换算成这一层的等比缩放。
|
||||
* 原先交给 CSS perspective 做,但每帧的投影光栅化太贵;
|
||||
* 平面卡片在固定 z 上做投影变换,结果恰好就是等比缩放加向中轴平移,所以自己算更省。
|
||||
*/
|
||||
function depthScale(depth: number): number {
|
||||
return PERSPECTIVE / (PERSPECTIVE + STEP_Z * depth);
|
||||
}
|
||||
|
||||
function shuffled<T>(list: T[]): T[] {
|
||||
const next = [...list];
|
||||
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||
const swap = Math.floor(Math.random() * (index + 1));
|
||||
[next[index], next[swap]] = [next[swap], next[index]];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function readRecent(mode: CreationMode): string[] {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(`${RECENT_KEY}:${mode}`);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecent(mode: CreationMode, ids: string[]) {
|
||||
try {
|
||||
window.sessionStorage.setItem(`${RECENT_KEY}:${mode}`, JSON.stringify(ids.slice(-RECENT_MEMORY)));
|
||||
} catch {
|
||||
// 隐私模式下 sessionStorage 可能不可用;退化成每次纯随机,不影响功能。
|
||||
}
|
||||
}
|
||||
|
||||
/** 随机抽一批:先挑没在最近出现过的,不够再用出现过的补齐,抽完把结果记进"最近"。 */
|
||||
function pickBatch(pool: CreationInspiration[], size: number, mode: CreationMode): CreationInspiration[] {
|
||||
if (pool.length <= size) return shuffled(pool);
|
||||
const recent = new Set(readRecent(mode));
|
||||
const byFreshness = [...shuffled(pool.filter((item) => !recent.has(catalogIdOf(item)))), ...shuffled(pool.filter((item) => recent.has(catalogIdOf(item))))];
|
||||
const picked = byFreshness.slice(0, size);
|
||||
writeRecent(mode, [...recent, ...picked.map(catalogIdOf)]);
|
||||
return picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画廊只服务鼠标氛围:读屏与键盘用户走输入框和快捷入口创作,
|
||||
* 所以卡片整体对辅助技术隐藏、也不进 Tab 顺序;左右切换按钮仍可聚焦,
|
||||
* 当前展示的是哪一张由下面那条 aria-live 文本播报。
|
||||
*/
|
||||
export function CreationInspirationTunnel({ mode, onStartPrompt }: { mode: CreationMode; onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
|
||||
/**
|
||||
* 池子来自 gallery-pool.json,是单独一份静态 JSON,不打包。
|
||||
* 它没到之前整条画廊不渲染——先摆一小批再换成大池子,用户会看到画面整排重排一次。
|
||||
*/
|
||||
const [pool, setPool] = useState<CreationInspiration[]>([]);
|
||||
const [poolReady, setPoolReady] = useState(false);
|
||||
const [shots, setShots] = useState<CreationInspiration[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [readyIds, setReadyIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const reducedMotion = useReducedMotion();
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef(0);
|
||||
activeRef.current = active;
|
||||
|
||||
const modePool = useMemo(() => pool.filter((item) => item.mode === mode), [pool, mode]);
|
||||
/** 轮换时读最新的池子,但不把池子写进轮播的依赖——否则池子一到就重置计时。 */
|
||||
const modePoolRef = useRef(modePool);
|
||||
modePoolRef.current = modePool;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// 加载器不会 reject:拉不到就退回打包的原创条目,画廊宁可少几张也不该空着。
|
||||
loadGalleryPool().then((entries) => {
|
||||
if (!alive) return;
|
||||
setPool(entries);
|
||||
setPoolReady(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 抽一批的时机只有两个:池子到货(含进场),以及换创作类型。
|
||||
useEffect(() => {
|
||||
if (!poolReady) return;
|
||||
setShots(pickBatch(modePool, GALLERY_SIZE, mode));
|
||||
setActive(0);
|
||||
}, [mode, modePool, poolReady]);
|
||||
|
||||
/** 封面就绪(含加载失败)后放行这张卡。失败的也要放行,否则卡片永远不出现。 */
|
||||
const markReady = useCallback((id: string) => {
|
||||
setReadyIds((current) => (current.has(id) ? current : new Set(current).add(id)));
|
||||
}, []);
|
||||
|
||||
// 兜底放行:请求挂住不返回时,到点把这一批全部放出来,宁可露出空面板也不留缺口。
|
||||
useEffect(() => {
|
||||
if (!shots.length) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setReadyIds((current) => new Set([...current, ...shots.map(catalogIdOf)]));
|
||||
}, COVER_FALLBACK_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [shots]);
|
||||
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
if (!shots.length) return;
|
||||
setActive((current) => (current + delta + shots.length) % shots.length);
|
||||
},
|
||||
[shots.length],
|
||||
);
|
||||
|
||||
// 自动轮播:走完一轮就换一批新的,用户不会反复看到同样那几张。
|
||||
useEffect(() => {
|
||||
if (paused || reducedMotion || shots.length < 2) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const next = activeRef.current + 1;
|
||||
if (next < shots.length) {
|
||||
setActive(next);
|
||||
return;
|
||||
}
|
||||
setShots(pickBatch(modePoolRef.current, GALLERY_SIZE, mode));
|
||||
setActive(0);
|
||||
}, AUTO_ADVANCE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [paused, reducedMotion, shots.length, mode]);
|
||||
|
||||
// 标签页切到后台时停掉,回来再继续,免得空转。
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => setPaused(document.hidden);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}, []);
|
||||
|
||||
// 滚轮切换:向上退到上一张、向下进到下一张。
|
||||
useEffect(() => {
|
||||
const node = stageRef.current;
|
||||
if (!node) return;
|
||||
let latched = false;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
// 触控板一次滑动会连发多个事件,锁一小段时间,避免一次滑过好几张。
|
||||
if (latched) return;
|
||||
latched = true;
|
||||
window.setTimeout(() => {
|
||||
latched = false;
|
||||
}, 240);
|
||||
step(event.deltaY > 0 ? 1 : -1);
|
||||
};
|
||||
node.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => node.removeEventListener("wheel", handleWheel);
|
||||
}, [step]);
|
||||
|
||||
const current = shots[active];
|
||||
|
||||
if (!shots.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="creation-inspiration-tunnel"
|
||||
onPointerEnter={() => setPaused(true)}
|
||||
onPointerLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={() => setPaused(false)}
|
||||
>
|
||||
<div ref={stageRef} className="creation-gallery-stage" aria-hidden="true">
|
||||
{shots.map((inspiration, index) => {
|
||||
const id = catalogIdOf(inspiration);
|
||||
// 相对当前卡片的距离,绕成最短路径,形成首尾相接的队列。
|
||||
const half = Math.floor(shots.length / 2);
|
||||
const relative = ((index - active + half + shots.length) % shots.length) - half;
|
||||
|
||||
const depth = Math.abs(relative);
|
||||
// 超出可见层数就靠透明度淡出,不切 visibility——
|
||||
// 硬切会让最远那张在过渡中途"啪"地消失。
|
||||
const style = {
|
||||
"--card-x": `${(Math.sign(relative) * STEP_X * depth).toFixed(1)}px`,
|
||||
"--card-scale": depthScale(depth).toFixed(4),
|
||||
"--card-depth-opacity": `${DEPTH_OPACITY[Math.min(depth, DEPTH_OPACITY.length - 1)]}`,
|
||||
zIndex: `${100 - depth}`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`creation-tunnel-card ${depth === 0 ? "is-active" : ""} ${readyIds.has(id) ? "is-ready" : ""}`}
|
||||
style={style}
|
||||
onClick={() => onStartPrompt(inspiration.mode, inspiration.prompt)}
|
||||
>
|
||||
<img
|
||||
src={inspiration.image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
onLoad={() => markReady(id)}
|
||||
onError={() => markReady(id)}
|
||||
// 命中浏览器缓存的图可能在挂监听之前就 complete 了,onLoad 不会再触发,这里补一次。
|
||||
ref={(node) => {
|
||||
if (node?.complete) markReady(id);
|
||||
}}
|
||||
/>
|
||||
{/* 三段式提示词信息:行动点、标题、描述,只有最前那张露出来。 */}
|
||||
<span className="creation-tunnel-card-info">
|
||||
<span className="creation-tunnel-card-cta">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
使用这个创意
|
||||
</span>
|
||||
<span className="creation-tunnel-card-title">{inspiration.title}</span>
|
||||
<span className="creation-tunnel-card-desc">{inspiration.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 图标外面套一层 span 是必须的:workspace-product.css 里有一条
|
||||
`button:has(> svg):not(:has(> span)):not(:disabled):hover` 会认领"纯图标按钮",
|
||||
用 !important 改写 color / background 并把 transform 顶成 translateY(-1px),
|
||||
按钮会当场掉半个身位。带 span 的按钮不在那条规则的射程内。 */}
|
||||
<button type="button" className="creation-gallery-arrow is-prev" aria-label="上一张灵感" onClick={() => step(-1)}>
|
||||
<span className="creation-gallery-arrow-icon"><ChevronLeft aria-hidden="true" /></span>
|
||||
</button>
|
||||
<button type="button" className="creation-gallery-arrow is-next" aria-label="下一张灵感" onClick={() => step(1)}>
|
||||
<span className="creation-gallery-arrow-icon"><ChevronRight aria-hidden="true" /></span>
|
||||
</button>
|
||||
|
||||
{/* 卡片本身对辅助技术隐藏,切换结果靠这条播报传达。 */}
|
||||
<p className="creation-gallery-announce" aria-live="polite">
|
||||
{current ? `${current.title}:${current.description}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { CreationMode } from "./creation-types";
|
||||
|
||||
export type CreationInspiration = { title: string; description: string; image: string; mode: CreationMode; prompt: string; featured?: boolean; source?: string };
|
||||
|
||||
export const creationFeaturedWorks: CreationInspiration[] = [
|
||||
{
|
||||
title: "雨夜霓虹 · 电影感开场",
|
||||
description: "宽银幕构图、环境反光与缓慢推进镜头",
|
||||
image: "/short-drama-styles/cyberpunk-neon.jpg",
|
||||
mode: "video",
|
||||
prompt: "雨夜城市街口,霓虹灯倒映在湿润路面,主角撑伞穿过人群,镜头从远景缓慢推进到侧脸特写,电影感光影,16:9",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "港风天台重逢",
|
||||
description: "复古色彩与克制的人物情绪",
|
||||
image: "/short-drama-styles/retro-hong-kong.jpg",
|
||||
mode: "video",
|
||||
prompt: "九十年代港风天台,黄昏逆光,两位多年未见的旧友隔着晾衣绳对望,风吹动衣角,镜头轻微手持并缓慢靠近",
|
||||
},
|
||||
{
|
||||
title: "暗巷悬疑追逐",
|
||||
description: "高反差黑色电影与紧张节奏",
|
||||
image: "/short-drama-styles/suspense-noir.jpg",
|
||||
mode: "video",
|
||||
prompt: "黑白悬疑电影风格,侦探在狭窄暗巷中追逐神秘人,路灯切割出强烈明暗,低机位跟拍,节奏逐渐加快",
|
||||
},
|
||||
{
|
||||
title: "东方幻想角色设定",
|
||||
description: "角色概念图与细腻材质表达",
|
||||
image: "/short-drama-styles/fantasy-3d.jpg",
|
||||
mode: "image",
|
||||
prompt: "东方幻想世界的年轻女剑客角色设定,银白发饰、层叠轻甲与红色披风,山雾背景,细腻材质,电影级概念设计",
|
||||
},
|
||||
{
|
||||
title: "温暖室内对话",
|
||||
description: "生活化场景与柔和叙事氛围",
|
||||
image: "/welcome/wing-it/barn.webp",
|
||||
mode: "text",
|
||||
prompt: "请帮我写一场发生在暖色灯光客厅里的母女对话戏:女儿准备离开家乡,母亲表面平静但一直在整理旧物,用动作和潜台词推进情绪。",
|
||||
},
|
||||
{ title: "森林里的最后一封信", description: "童话叙事 · 从角色愿望建立冲突", image: "/short-drama-styles/storybook-fantasy.jpg", mode: "text", source: "Storyteller", prompt: "作为故事讲述者,为亲子观众写一个关于坚持的森林童话。主角是一只替动物送信的小狐狸,它必须在第一场雪之前送出一封没有地址的信。先给出角色的愿望与阻碍,再设计三个递进的事件,最后用一个可见的行动完成情绪转折。避免说教。" },
|
||||
{ title: "末班车上的陌生人", description: "短剧编剧 · 让对白藏住真正目的", image: "/short-drama-styles/urban-live-action.jpg", mode: "text", source: "Screenwriter", prompt: "作为编剧,设计一段发生在末班公交车上的三分钟短剧。两位陌生人都声称自己丢失了一本笔记,但只有一人说真话。先定义人物动机、场景限制和各自隐瞒的信息,再写动作、对白与反转结尾。全剧限制两个角色、一个场景,适合低成本实拍。" },
|
||||
{ title: "未来城市的记忆修补师", description: "科幻大纲 · 世界规则与人物代价", image: "/short-drama-styles/future-tech.jpg", mode: "text", source: "Novelist", prompt: "作为小说家,构思一部关于记忆修补师的未来都市短篇。城市居民能删除痛苦记忆,但主角发现每次删除都会遗失一种颜色。建立三条世界规则、主人公的核心缺陷、五个转折和付出代价的结局。先交付故事大纲,不直接铺写长篇。" },
|
||||
{ title: "把告别写成一场雨", description: "诗性旁白 · 少解释,多感官细节", image: "/short-drama-styles/nature-healing.jpg", mode: "text", source: "Poet", prompt: "作为诗人,为一段雨中离乡的影像写四组中文旁白,每组不超过四十字。分别使用气味、触感、声音与光线表达告别,不直接使用悲伤、不舍或想念等情绪词。用朴素、可朗读的语言,给每组留一个停顿。" },
|
||||
{ title: "一瓶饮料的夏日故事", description: "广告策划 · 人群、主张与三个镜头", image: "/short-drama-styles/real-life.jpg", mode: "text", source: "Advertiser", prompt: "作为广告策划,为面向年轻通勤者的无糖气泡饮料设计十五秒广告。定义目标人群、核心场景和一句传播主张,再给出三个镜头的画面、时长、音效与字幕。不要虚构营养或功效数据,用下班后一口清爽的体验讲故事。" },
|
||||
{ title: "独立咖啡店的视觉语言", description: "品牌创意 · 让配色和语气讲同一件事", image: "/short-drama-styles/retro-hong-kong.jpg", mode: "text", source: "Creative Branding Strategist", prompt: "作为品牌策略师,为社区里一家安静的独立咖啡店提出三种视觉方向。品牌重视慢节奏、邻里关系和手作。每种方向包含色彩、摄影氛围、文字语气、三条社交内容主题和差异化理由。不要模仿已有连锁品牌的标识。" },
|
||||
{ title: "给剧本做一次镜头诊断", description: "创作复盘 · 从叙事到声音提出修改", image: "/short-drama-styles/black-white-noir.jpg", mode: "text", source: "Film Critic", prompt: "请以电影评论者的视角审阅我接下来提供的剧本或分镜,从叙事、人物、构图、运镜和声音五个维度提出反馈。每条意见都引用我提供的片段,并给出一个能实际拍摄的修改方案。还没有收到材料时,先向我索取,不要假装看过影片。" },
|
||||
{ title: "旧城街景的时代考据", description: "历史场景 · 区分史实、推测和虚构", image: "/short-drama-styles/period-live-action.jpg", mode: "text", source: "Historian", prompt: "作为历史研究者,协助我设计民国早期城市街景的影视场景。先问清具体年份、地点和人物阶层,再列出服饰、交通、招牌、建筑和日常用语的考据清单。明确区分有来源的史实、合理推测与戏剧性虚构,不编造文献。" },
|
||||
{ title: "月面独行", description: "远景到特写 · 太空探索的孤独感", image: "/short-drama-styles/space-opera.jpg", mode: "video", prompt: "月球背面的荒原,宇航员独自走向一座废弃天线。镜头从极远景缓慢推进,低角度阳光在月尘上拉出长影,步伐沉重,头盔反射星光,克制冷色,宽银幕电影构图,无字幕。" },
|
||||
{ title: "手作星球早餐", description: "定格动画 · 柔软材质与小动作", image: "/short-drama-styles/clay-stop-motion.jpg", mode: "video", prompt: "黏土定格动画风格,一位小小宇航员在圆形厨房煎一颗星星形状的鸡蛋,鸡蛋轻轻跳起。手作指纹质感、暖奶油色、柔和侧光,固定中景,动作简短连续,可爱但不夸张。" },
|
||||
{ title: "山水之间的一叶舟", description: "水墨留白 · 横向移镜与轻雾", image: "/short-drama-styles/ink-narrative.jpg", mode: "video", prompt: "中国水墨动画,一叶小舟缓缓穿过层叠远山,薄雾留白,舟上人抬手触碰落下的花瓣。镜头平稳横移,墨色有自然晕染,少量朱红点缀,画面安静,动作节奏舒缓。" },
|
||||
{ title: "雨后的玻璃花房", description: "治愈场景 · 前景水滴与逆光", image: "/short-drama-styles/nature-healing.jpg", mode: "video", prompt: "清晨雨后的玻璃花房,镜头透过挂着水滴的叶片缓缓推向盛开的白花,阳光从侧后方进入,浅景深,真实植物纹理,空气中薄薄水汽,宁静自然,无人物和文字。" },
|
||||
{ title: "都市少女角色转面", description: "角色概念 · 统一服装与形体比例", image: "/short-drama-styles/chinese-2d.jpg", mode: "image", prompt: "原创二维动画都市少女角色设定,短发、宽松工装夹克和帆布包,正面、侧面与背面三视图,服装细节和身高比例一致,干净线稿配平涂阴影,暖灰背景,不添加文字,不模仿已有动漫角色。" },
|
||||
{ title: "梦境里的倒置房间", description: "超现实概念 · 重力反转与柔光", image: "/short-drama-styles/surreal-dream.jpg", mode: "image", prompt: "超现实电影场景概念图,一间漂浮在云海中的倒置卧室,床和书桌悬在天花板上,一扇门通向浅粉色天空。低饱和奶油与雾蓝色,柔和漫反射,精确透视,细腻材质,无人物,无文字。" },
|
||||
{ title: "海边旅店的黄昏", description: "实拍场景 · 用道具留下人物痕迹", image: "/short-drama-styles/urban-live-action.jpg", mode: "image", prompt: "沿海小城旧旅店房间的电影置景概念图,窗外是黄昏海面,风吹动米白窗帘,桌上有两杯没有喝完的茶和一本翻开的书。暖橙与浅蓝对比,自然胶片颗粒,眼平中广角,没有人物和可读文字。" },
|
||||
{ title: "鲜亮漫画封面", description: "平面构成 · 大色块与戏剧性透视", image: "/short-drama-styles/comic-pop.jpg", mode: "image", prompt: "原创漫画封面插画,一位骑单车的城市信使跃过雨后积水,戏剧性低角度透视,青蓝、珊瑚红与暖黄色大色块,半调网点,清晰黑色轮廓,背景简洁,为顶部标题留白但不生成文字。" },
|
||||
{ title: "烘焙店的秘密帮手", description: "三维角色 · 柔和材质与亲切表情", image: "/short-drama-styles/three-d-cartoon.jpg", mode: "image", prompt: "原创三维动画角色概念图,一只穿着面粉围裙的小浣熊站在烘焙台前,鼻尖沾着面粉,手里举着刚烤好的面包。柔软毛发、温暖侧光、自然可爱的比例,橘棕色和奶油白配色,背景轻微虚化。" },
|
||||
];
|
||||
|
||||
export const inspirationSource = {
|
||||
repository: "https://github.com/f/awesome-chatgpt-prompts",
|
||||
revision: "f78a1c5136fa080155d928e0d7e2b4a41ddef03e",
|
||||
license: "CC0-1.0",
|
||||
notice: "8 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { ArrowUp, ChevronDown, Clapperboard, FileText, Image as ImageIcon, Spark
|
||||
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations";
|
||||
import { curatedInspirations, inspirationSourceOf, inspirationSources } from "@/lib/inspirations/catalog";
|
||||
import { modeLabels } from "./creation-types";
|
||||
import { Button } from "antd";
|
||||
|
||||
@@ -82,14 +82,18 @@ export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStart
|
||||
export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
|
||||
const [filter, setFilter] = useState<"all" | CreationMode>("all");
|
||||
const [limit, setLimit] = useState(12);
|
||||
const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter);
|
||||
const filtered = curatedInspirations.filter((item) => filter === "all" || item.mode === filter);
|
||||
// 只列出真正用到的外部来源;原创条目不写来源声明。
|
||||
const declaredSources = Array.from(new Set(curatedInspirations.map((item) => item.sourceId ?? "original")))
|
||||
.map((id) => inspirationSources[id])
|
||||
.filter((source) => source.notice);
|
||||
return (
|
||||
<section className="creation-featured-works" aria-labelledby="creation-featured-title">
|
||||
<div className="creation-featured-heading">
|
||||
<div>
|
||||
<h2 id="creation-featured-title">精选灵感</h2>
|
||||
</div>
|
||||
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
|
||||
<p>{curatedInspirations.length} 个创意起点 · 点击填入提示词,不自动生成</p>
|
||||
</div>
|
||||
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
|
||||
{(["all", "video", "image", "text"] as const).map((value) => (
|
||||
@@ -103,7 +107,7 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
|
||||
}}
|
||||
>
|
||||
{value === "all" ? "全部灵感" : modeLabels[value]}
|
||||
<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span>
|
||||
<span>{curatedInspirations.filter((item) => value === "all" || item.mode === value).length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -122,7 +126,7 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
|
||||
<span>{item.description}</span>
|
||||
<em>
|
||||
<Sparkles />
|
||||
{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}
|
||||
{inspirationSourceOf(item).label} · {modeLabels[item.mode]}
|
||||
</em>
|
||||
</span>
|
||||
</button>
|
||||
@@ -139,10 +143,16 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
|
||||
)}
|
||||
<details>
|
||||
<summary>模板与封面来源</summary>
|
||||
<p>{inspirationSource.notice}</p>
|
||||
<a href={inspirationSource.repository} target="_blank" rel="noreferrer">
|
||||
awesome-chatgpt-prompts · CC0
|
||||
</a>
|
||||
{declaredSources.map((source) => (
|
||||
<div key={source.name ?? source.label}>
|
||||
<p>{source.notice}</p>
|
||||
{source.repository ? (
|
||||
<a href={source.repository} target="_blank" rel="noreferrer">
|
||||
{source.name} · {source.license}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { App, Spin } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { History, Sparkles, Maximize2 } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
|
||||
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||||
@@ -31,7 +31,9 @@ import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreation
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
|
||||
import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations";
|
||||
import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
|
||||
import { CreationComposer, CreationEmptySuggest, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
|
||||
import { CreationInspirationTunnel } from "./creation-inspiration-tunnel";
|
||||
import { declaredInspirationSources } from "@/lib/inspirations/catalog";
|
||||
import { CreationAgentEntry } from "./creation-agent-entry";
|
||||
import { createCreationSubmitGate } from "./creation-submit-gate";
|
||||
import { creationVideoConfig } from "./creation-generation-config";
|
||||
@@ -75,6 +77,7 @@ export default function CreatePage() {
|
||||
const [agentMode, setAgentMode] = useState(false);
|
||||
const { message: toast, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [openingCanvas, setOpeningCanvas] = useState(false);
|
||||
const openingCanvasRef = useRef(false);
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
@@ -391,6 +394,21 @@ export default function CreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 灵感库「用这个创意创作」跳过来时把提示词和类型带上。
|
||||
// 用完立刻清掉路由状态:留着的话,用户前进后退回到这一页会被重复填充一次。
|
||||
const inspirationHandoff = location.state as { inspirationPrompt?: string; inspirationMode?: CreationMode } | null;
|
||||
useEffect(() => {
|
||||
const handoffPrompt = inspirationHandoff?.inspirationPrompt;
|
||||
if (!handoffPrompt) return;
|
||||
setAgentMode(false);
|
||||
if (inspirationHandoff?.inspirationMode) selectMode(inspirationHandoff.inspirationMode);
|
||||
setPrompt(handoffPrompt);
|
||||
navigate(".", { replace: true, state: null });
|
||||
window.requestAnimationFrame(() => composerFocusRef.current?.focus());
|
||||
// selectMode 每次渲染都是新函数,放进依赖会让本效果反复触发;靠 state 置空来收敛即可。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inspirationHandoff, navigate]);
|
||||
|
||||
const setComposerRatio = (value: string) => {
|
||||
setRatio(value);
|
||||
if (mode === "image") rememberImageSettings({ ratio: value });
|
||||
@@ -1046,6 +1064,10 @@ export default function CreatePage() {
|
||||
}}><Maximize2 /></button></Tooltip>
|
||||
</motion.div> : null}
|
||||
</AnimatePresence>
|
||||
<CreationInspirationTunnel
|
||||
mode={mode}
|
||||
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/>
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
|
||||
<div className="creation-home-heading">
|
||||
<h1>和{brandName}聊聊创作想法</h1>
|
||||
@@ -1061,10 +1083,25 @@ export default function CreatePage() {
|
||||
onOpenLibrary={() => { setAgentMode(false); selectMode("image"); setLibraryOpen(true); }}
|
||||
/>
|
||||
</section>
|
||||
<CreationFeaturedWorks
|
||||
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/>
|
||||
</main>
|
||||
</main>
|
||||
<div className="creation-inspiration-credit">
|
||||
<details>
|
||||
<summary>素材来源</summary>
|
||||
{declaredInspirationSources().map((source) => (
|
||||
<p key={source.name ?? source.label}>
|
||||
{source.notice}
|
||||
{source.repository ? (
|
||||
<>
|
||||
{" "}
|
||||
<a href={source.repository} target="_blank" rel="noreferrer">
|
||||
{source.name} · {source.license}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
</div>
|
||||
</> : <div className="creation-thread-workbench">
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||||
|
||||
Reference in New Issue
Block a user