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:
地球观察员
2026-10-08 19:55:04 +08:00
committed by GitHub
parent 295374650a
commit 04689257d2
384 changed files with 3018 additions and 84 deletions

View File

@@ -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"];

View 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;
}
}

View 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>
);
}

View File

@@ -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 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
};

View File

@@ -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>

View File

@@ -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">