feat(web): InfiPlot low-fi homepage with AI-generated cards + gender-reactive hero + audio toggle fix
Rebuilds the landing page from the prototype: 1900px scale-to-fit hero with hand-drawn SVG-jitter frames, typewriter input + start button, 5 horizontal collapsible category selectors (with style-picker modal), 7 scattered hero cards over a 16-card masonry gallery, and project intro panel. Each card is filled with a Runware FLUX.2 image, pre-generated and stored as WebP (~2 MB total for 30 cards). Hero card content + image switches by 性向 (男性向 / 女性向); gallery stays shared. Hover overlay on every card shows title + outline in a bottom-up dark gradient, matching the prior homepage's interaction style. Bug fixes uncovered by tracing the form-state → engine pipeline: - 「语音配音:关闭」was previously stuffed into styleGuide (consumed only by FLUX, ignored by TTS). Now serialized as audioEnabled boolean in the sessionStorage payload; play page's fetchBeatAudio early-returns when false, so no /api/beat-audio request fires. - 「绘画风格:自动」used to pass the literal Chinese phrase "由模型根据 prompt 自动判断画风" to FLUX, which painted it as text. Now maps to the 二次元/galgame default prompt. Adds reusable scripts under apps/web/scripts/: - generate-home-images.mjs — Runware FLUX.2 idempotent batch generator - optimize-home-images.mjs — sharp WebP downscale + recompress Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -2,67 +2,650 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==================== InfiPlot — low-fi prototype tokens ==================== */
|
||||
:root {
|
||||
--ink: #3a3a38;
|
||||
--ink-soft: #6f6e69;
|
||||
--ink-faint: #a9a7a0;
|
||||
--line: #cfccc4;
|
||||
--paper: #f3f1ec;
|
||||
--paper-2: #e9e6df;
|
||||
--fill: #ddd9d0;
|
||||
--accent: #d4824a;
|
||||
--jit: 1;
|
||||
--sketch-filter: url(#s2);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-feature-settings: "ss01", "kern", "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px);
|
||||
background-size: 28px 28px, 38px 38px;
|
||||
background-position: 0 0, 14px 19px;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 38px, rgba(0, 0, 0, 0.018) 38px 39px),
|
||||
var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgb(217 122 46 / 0.28);
|
||||
color: #2d1810;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: rgb(168 105 59 / 0.45);
|
||||
background-color: rgb(212 130 74 / 0.28);
|
||||
color: var(--ink);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.hairline {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(45, 24, 16, 0.18) 18%,
|
||||
rgba(45, 24, 16, 0.18) 82%,
|
||||
transparent
|
||||
);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.hairline-full {
|
||||
height: 1px;
|
||||
background: rgba(45, 24, 16, 0.14);
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.smallcaps {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.32em;
|
||||
.latin {
|
||||
font-family: "Patrick Hand", "Caveat", "Cormorant Garamond", cursive;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes yume-ripple {
|
||||
0% {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
opacity: 0;
|
||||
}
|
||||
/* ==================== hand-drawn frame ==================== */
|
||||
.frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 12px;
|
||||
filter: var(--sketch-filter);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.frame.soft {
|
||||
border-color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* ==================== logo ==================== */
|
||||
.ip-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.ip-logo .mark {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.ip-logo .mark .frame {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ip-logo .mark span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 15px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.ip-logo .word {
|
||||
font-family: "Patrick Hand", "Caveat", cursive;
|
||||
font-size: 24px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--ink);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ==================== input bar (prompt + start) ==================== */
|
||||
.ip-tagline {
|
||||
font-size: clamp(22px, 2.6vw, 33px);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
.ip-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
width: min(1100px, 92vw);
|
||||
height: 68px;
|
||||
}
|
||||
.ip-field {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.ip-field .frame {
|
||||
border-radius: 16px;
|
||||
}
|
||||
.ip-field input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
color: var(--ink);
|
||||
z-index: 1;
|
||||
}
|
||||
.ip-field input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
.ip-field .ph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 28px;
|
||||
font-size: 20px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 300;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.ip-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 23px;
|
||||
background: var(--ink-faint);
|
||||
margin: 0 1px 0 3px;
|
||||
vertical-align: -4px;
|
||||
animation: ip-blink 1.1s steps(1) infinite;
|
||||
}
|
||||
@keyframes ip-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.ip-start {
|
||||
position: relative;
|
||||
width: 176px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.ip-start .frame {
|
||||
border-radius: 16px;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
z-index: 0;
|
||||
}
|
||||
.ip-start span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
letter-spacing: 6px;
|
||||
font-weight: 500;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.ip-start:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== collapsible category pills ==================== */
|
||||
.ip-cat {
|
||||
position: relative;
|
||||
}
|
||||
.ip-catbtn {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.ip-catbtn .frame {
|
||||
border-radius: 21px;
|
||||
border-color: var(--line);
|
||||
}
|
||||
.ip-catname {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.ip-catval {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ip-caret {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 11px;
|
||||
color: var(--ink-soft);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.ip-cat.open .ip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ip-cat.open .ip-catbtn .frame {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ip-cat.open .ip-catval {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ip-catmenu {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
min-width: calc(100% + 8px);
|
||||
padding: 7px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--paper);
|
||||
}
|
||||
.ip-catmenu .frame {
|
||||
border-radius: 12px;
|
||||
border-color: var(--ink-soft);
|
||||
}
|
||||
.ip-catopt {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.ip-catopt:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.ip-catopt.on {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ip-catopt.on::after {
|
||||
content: "\2713";
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==================== scattered story cards ==================== */
|
||||
.ip-card {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.ip-card:hover {
|
||||
transform: rotate(0deg) translateY(-4px) !important;
|
||||
}
|
||||
.ip-card .inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ip-card .img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--fill);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.ip-card .img svg {
|
||||
width: 40%;
|
||||
max-width: 120px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ip-card .img img.card-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ip-card .frame {
|
||||
z-index: 3;
|
||||
}
|
||||
/* hover overlay: bottom-up dark gradient with title + outline */
|
||||
.ip-hover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 18px 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.45) 45%, rgba(0, 0, 0, 0) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
user-select: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ip-card:hover .ip-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.ip-hover-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #fff;
|
||||
}
|
||||
.ip-hover-outline {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgba(231, 226, 215, 0.92);
|
||||
font-style: italic;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== gallery masonry ==================== */
|
||||
.ip-gallery {
|
||||
width: min(1640px, 96vw);
|
||||
margin: 14px auto 0;
|
||||
column-count: 4;
|
||||
column-gap: 24px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.ip-gallery { column-count: 3; }
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.ip-gallery { column-count: 2; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.ip-gallery { column-count: 1; }
|
||||
}
|
||||
.ip-card.gcard {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
margin: 0 0 24px;
|
||||
break-inside: avoid;
|
||||
transform: rotate(calc(var(--gr, 0deg) * var(--jit)));
|
||||
}
|
||||
|
||||
.ip-sectionnote {
|
||||
width: 100%;
|
||||
margin: 34px auto 6px;
|
||||
text-align: center;
|
||||
font-family: "Patrick Hand", "Caveat", cursive;
|
||||
font-size: 19px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.ip-sectionnote .arr {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ==================== project intro ==================== */
|
||||
.ip-intro {
|
||||
position: relative;
|
||||
width: min(1500px, 94vw);
|
||||
margin: 70px auto 110px;
|
||||
padding: 58px clamp(28px, 5vw, 80px) 64px;
|
||||
}
|
||||
.ip-intro .frame {
|
||||
border-radius: 16px;
|
||||
border-color: var(--ink-soft);
|
||||
}
|
||||
.ip-intro .imark {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ip-intro .imark .gl {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
color: var(--ink-soft);
|
||||
border: 2px solid var(--ink-soft);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ip-intro h2 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
font-family: "Patrick Hand", "Caveat", cursive;
|
||||
font-size: 38px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--ink);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ip-intro .kicker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 15px;
|
||||
color: var(--ink-soft);
|
||||
letter-spacing: 3px;
|
||||
margin: 2px 0 26px;
|
||||
font-family: "Patrick Hand", "Caveat", cursive;
|
||||
}
|
||||
.ip-intro p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1180px;
|
||||
font-size: 18px;
|
||||
line-height: 1.9;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 18px;
|
||||
font-weight: 300;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.ip-intro .label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 13px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 2px;
|
||||
margin: 26px 0 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ip-intro b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ip-intro .mail {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== style picker modal ==================== */
|
||||
.ip-modal-ov {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: rgba(40, 38, 34, 0);
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
transition: background 0.28s ease, backdrop-filter 0.28s ease, -webkit-backdrop-filter 0.28s ease;
|
||||
}
|
||||
.ip-modal-ov.show {
|
||||
background: rgba(40, 38, 34, 0.34);
|
||||
backdrop-filter: blur(7px);
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
}
|
||||
.ip-modal {
|
||||
position: relative;
|
||||
width: 1120px;
|
||||
max-width: 94vw;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.32);
|
||||
transform: scale(0.92);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.2, 0.82, 0.25, 1), opacity 0.24s ease;
|
||||
}
|
||||
.ip-modal-ov.show .ip-modal {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
.ip-modal .frame {
|
||||
border-radius: 16px;
|
||||
border-color: var(--ink-soft);
|
||||
z-index: 0;
|
||||
}
|
||||
.ip-modal-hd {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 22px 26px;
|
||||
border-bottom: 1.5px dashed var(--line);
|
||||
}
|
||||
.ip-modal-ttl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ip-modal-sub {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.ip-modal-search {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
width: 320px;
|
||||
max-width: 46vw;
|
||||
}
|
||||
.ip-modal-search input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 40px 0 16px;
|
||||
border-radius: 21px;
|
||||
border: 1.5px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
.ip-modal-search input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.ip-modal-search .si {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--ink-faint);
|
||||
font-size: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ip-modal-x {
|
||||
font-size: 28px;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.ip-modal-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
padding: 24px 26px 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.ip-modal-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.ip-scard {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ip-scard .sthumb {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
background: var(--fill);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ip-scard .sthumb svg {
|
||||
width: 32%;
|
||||
max-width: 84px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ip-scard .sthumb::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 11px;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.ip-scard.on .sthumb::after {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ip-scard .sname {
|
||||
text-align: center;
|
||||
padding: 10px 4px 2px;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.ip-scard.on .sname {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ip-noresult {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
padding: 48px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== avatar (bottom-left of hero) ==================== */
|
||||
.ip-avatar {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
.ip-avatar .frame {
|
||||
border-radius: 50%;
|
||||
border-color: var(--ink-soft);
|
||||
}
|
||||
.ip-avatar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "Patrick Hand", "Caveat", cursive;
|
||||
font-size: 22px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-serif",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "云梦 — AI 视觉小说",
|
||||
description: "一部由 AI 实时绘制每一帧的开源视觉小说。",
|
||||
title: "InfiPlot — AI 实时交互剧情游戏",
|
||||
description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -28,18 +12,31 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${cormorant.variable} ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
href="https://fonts.googleapis.com/css2?family=Patrick+Hand&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap"
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen">
|
||||
<body className="min-h-screen overflow-x-hidden">
|
||||
{/* Hand-drawn jitter filters used by every .frame element */}
|
||||
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
|
||||
<filter id="s1">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.012" numOctaves="2" seed="7" result="n" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="1.2" />
|
||||
</filter>
|
||||
<filter id="s2">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.016" numOctaves="2" seed="4" result="n" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.6" />
|
||||
</filter>
|
||||
<filter id="s3">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.022" numOctaves="3" seed="11" result="n" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="4.2" />
|
||||
</filter>
|
||||
</svg>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,156 +1,640 @@
|
||||
import Link from "next/link";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import { PresetCard } from "@/components/PresetCard";
|
||||
"use client";
|
||||
|
||||
const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"];
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
export default function LandingPage() {
|
||||
/* ============================================================================
|
||||
InfiPlot · 低保真原型首页
|
||||
- 1900px 设计画布 + 等比缩放至视口宽度,最大程度还原原型版式
|
||||
- 顶部 Hero 浮动散落卡片;下方瀑布流;尾部项目介绍
|
||||
========================================================================== */
|
||||
|
||||
const HERO_CANVAS_W = 1900;
|
||||
const HERO_CANVAS_H = 980;
|
||||
|
||||
const EXAMPLE_PHRASES: Record<"男性向" | "女性向", string[]> = {
|
||||
男性向: [
|
||||
"从小一起长大的青梅竹马,突然红着脸向我告白",
|
||||
"一觉醒来,班上的女生好像都偷偷喜欢上了我",
|
||||
"三年之期已到,原来我是富家公子,报仇时机已到",
|
||||
"我带着无限 Token 穿越回了互联网诞生前夕……",
|
||||
],
|
||||
女性向: [
|
||||
"穿越成将军府的废物嫡女,冷面摄政王却独宠我一人",
|
||||
"重生回到分手前夜,这一次换我先放手",
|
||||
"一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局",
|
||||
],
|
||||
};
|
||||
|
||||
type Opt = {
|
||||
label: string;
|
||||
items: string[];
|
||||
defaultIndex?: number;
|
||||
modal?: boolean;
|
||||
};
|
||||
|
||||
const OPTS: Opt[] = [
|
||||
{ label: "性向", items: ["男性向", "女性向"] },
|
||||
{
|
||||
label: "绘画风格",
|
||||
modal: true,
|
||||
items: [
|
||||
"自动",
|
||||
"二次元",
|
||||
"吉卜力",
|
||||
"真实系",
|
||||
"超写实",
|
||||
"水彩",
|
||||
"像素风",
|
||||
"日系动画",
|
||||
"3D 渲染",
|
||||
"蒸汽朋克",
|
||||
"玄幻",
|
||||
"国风水墨",
|
||||
"赛博朋克",
|
||||
],
|
||||
},
|
||||
{ label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 },
|
||||
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
|
||||
{ label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 },
|
||||
];
|
||||
|
||||
/* Hero slot geometry — 7 fixed positions, contents switch by 性向 */
|
||||
const HERO_SLOTS = [
|
||||
{ x: 55, y: 470, w: 330, h: 196, rot: -1.6 },
|
||||
{ x: 55, y: 690, w: 330, h: 196, rot: 1.3 },
|
||||
{ x: 418, y: 566, w: 286, h: 352, rot: -1.1 },
|
||||
{ x: 765, y: 642, w: 326, h: 258, rot: 1.1 },
|
||||
{ x: 1130, y: 570, w: 326, h: 352, rot: 1.4 },
|
||||
{ x: 1492, y: 478, w: 358, h: 200, rot: 1.6 },
|
||||
{ x: 1492, y: 688, w: 358, h: 200, rot: -1.3 },
|
||||
] as const;
|
||||
|
||||
type HeroContent = { title: string; outline: string };
|
||||
|
||||
const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = {
|
||||
男性向: [
|
||||
{ title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" },
|
||||
{ title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" },
|
||||
{ title: "云海仙踪", outline: "凡骨少年偶得神秘残碑,登顶云海仙山,神魔同修之路自此开启。" },
|
||||
{ title: "六月雨季", outline: "南方县城的多雨六月,转学第一天,注意到那个总在天台读诗的同学。雨水打湿了未送出的伞……" },
|
||||
{ title: "雨夜霓虹", outline: "2087 年东亚特区的酸雨之夜,丢失了三天记忆的我,手腕终端响起一通匿名警告:「他们来找你了」。" },
|
||||
{ title: "学院秘闻", outline: "深夜图书馆地下密室,清冷孤僻的班长跪在圆环阵法前,吟诵着不属于人类的咒词。" },
|
||||
{ title: "异界召唤", outline: "再睁眼,没有班主任,只有昏暗的魔法阵与一位哭得梨花带雨的圣女:「勇者大人,请拯救这个世界。」" },
|
||||
],
|
||||
女性向: [
|
||||
{ title: "摄政王独宠", outline: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" },
|
||||
{ title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" },
|
||||
{ title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" },
|
||||
{ title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" },
|
||||
{ title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" },
|
||||
{ title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" },
|
||||
{ title: "学长的告白", outline: "夕阳染红了天台,那个总在篮球场被全校女生围观的学长,第一次叫住了我。" },
|
||||
],
|
||||
};
|
||||
|
||||
const GALLERY: Array<{ h: number; rot: number; title: string; outline: string }> = [
|
||||
{ h: 300, rot: -0.8, title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" },
|
||||
{ h: 200, rot: 0.6, title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" },
|
||||
{ h: 260, rot: 0.9, title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" },
|
||||
{ h: 330, rot: -0.6, title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" },
|
||||
{ h: 200, rot: 1.1, title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" },
|
||||
{ h: 300, rot: -1.0, title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" },
|
||||
{ h: 240, rot: 0.7, title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" },
|
||||
{ h: 200, rot: -0.7, title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" },
|
||||
{ h: 330, rot: 0.8, title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" },
|
||||
{ h: 200, rot: -1.1, title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" },
|
||||
{ h: 260, rot: 0.5, title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" },
|
||||
{ h: 300, rot: -0.6, title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" },
|
||||
{ h: 200, rot: 0.9, title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" },
|
||||
{ h: 330, rot: -0.9, title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" },
|
||||
{ h: 200, rot: 0.6, title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" },
|
||||
{ h: 260, rot: -0.7, title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" },
|
||||
];
|
||||
|
||||
/* ---------- shared primitives ---------- */
|
||||
|
||||
function ImgGlyph() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-[10px] smallcaps text-clay-700 font-medium">
|
||||
云梦
|
||||
</span>
|
||||
<span className="hairline w-10 hidden md:block" />
|
||||
<span className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
||||
逐 · 帧 · 而 · 成
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-5 text-[10px] smallcaps text-clay-600">
|
||||
<a
|
||||
href="https://github.com"
|
||||
className="hover:text-clay-900 transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<span className="text-clay-300">·</span>
|
||||
<a href="#about" className="hover:text-clay-900 transition-colors">
|
||||
关于
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
<svg viewBox="0 0 120 90" fill="none" stroke="#6f6e69" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x={6} y={6} width={108} height={78} rx={6} />
|
||||
<circle cx={38} cy={32} r={9} />
|
||||
<path d="M14 76 L46 46 L66 64 L84 44 L106 76" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
<section className="px-6 md:px-16 pt-20 md:pt-36 pb-20 md:pb-28">
|
||||
<div className="grid grid-cols-12 gap-8">
|
||||
<div className="col-span-12 md:col-span-7 animate-fade-in">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-8">
|
||||
一场 · 开源 · 实验 · MMXXVI
|
||||
</p>
|
||||
<h1 className="font-serif font-light text-[56px] md:text-[104px] leading-[0.94] text-clay-900 tracking-tight">
|
||||
每<em className="italic font-light text-clay-600">一帧</em>
|
||||
<br />
|
||||
都于
|
||||
<br />
|
||||
<span className="text-ember-500 italic font-light">此刻</span>
|
||||
诞生。
|
||||
</h1>
|
||||
<p className="mt-10 md:mt-14 max-w-md font-serif text-lg md:text-xl text-clay-700 leading-[1.65]">
|
||||
云梦是一部视觉小说 — 场景、对话、选项,<em>整个</em>界面皆由 AI
|
||||
一帧一帧绘成。你点击。它落笔。故事铺展。
|
||||
</p>
|
||||
</div>
|
||||
function Frame({ className = "" }: { className?: string }) {
|
||||
return <div className={"frame " + className} />;
|
||||
}
|
||||
|
||||
<aside className="col-span-12 md:col-span-4 md:col-start-9 mt-8 md:mt-0 flex md:items-end">
|
||||
<div className="space-y-3">
|
||||
<div className="hairline w-12" />
|
||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
||||
“人不能两次踏入同一条河流。
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
||||
你也不会两次走进同一个云梦。”
|
||||
</p>
|
||||
<p className="text-[10px] smallcaps text-clay-500 pt-2">
|
||||
— 自述 · v0.1
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="px-6 md:px-16">
|
||||
<div className="hairline-full w-full" />
|
||||
function CardBody({
|
||||
title,
|
||||
outline,
|
||||
image,
|
||||
}: {
|
||||
title: string;
|
||||
outline: string;
|
||||
image?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="inner">
|
||||
<div className="img">
|
||||
{image ? (
|
||||
<img className="card-photo" src={image} alt={title} loading="lazy" />
|
||||
) : (
|
||||
<ImgGlyph />
|
||||
)}
|
||||
</div>
|
||||
<div className="ip-hover">
|
||||
<h4 className="ip-hover-title">{title}</h4>
|
||||
<p className="ip-hover-outline">{outline}</p>
|
||||
</div>
|
||||
|
||||
<section className="px-6 md:px-16 pt-14 md:pt-20 pb-16 md:pb-24">
|
||||
<div className="flex items-baseline justify-between mb-8 md:mb-10">
|
||||
<h2 className="text-[10px] smallcaps text-clay-700 font-medium">
|
||||
四 扇 门
|
||||
</h2>
|
||||
<p className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
||||
择 一 世 界 · 或 自 行 编 织
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1">
|
||||
{PRESETS.map((p, i) => (
|
||||
<PresetCard key={p.id} preset={p} ordinal={ORDINALS[i]!} />
|
||||
))}
|
||||
|
||||
<Link
|
||||
href="/new"
|
||||
className="group block w-full py-10 md:py-12 border-t border-b border-clay-900/10 hover:border-clay-900/35 transition-[border-color] duration-500"
|
||||
>
|
||||
<div className="flex items-baseline gap-6 md:gap-10">
|
||||
<span className="font-serif italic text-2xl md:text-3xl text-clay-400 group-hover:text-clay-700 transition-colors duration-500 w-8 shrink-0">
|
||||
{ORDINALS[3]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-serif text-3xl md:text-4xl text-clay-900 leading-tight mb-2.5">
|
||||
无 题
|
||||
</h3>
|
||||
<p className="text-sm text-clay-600 leading-relaxed max-w-md">
|
||||
带来你自己的世界。用你自己的话讲述它。
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden md:flex items-center gap-3 text-[10px] tracking-[0.4em] text-clay-400 group-hover:text-ember-500 transition-colors duration-500 shrink-0 self-center">
|
||||
编 织
|
||||
<span className="w-7 h-px bg-current transition-all duration-500 group-hover:w-12" />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="about"
|
||||
className="px-6 md:px-16 pb-20 md:pb-28 grid grid-cols-12 gap-8"
|
||||
>
|
||||
<div className="col-span-12 md:col-span-3">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
题 跋 · I
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||
一场关于生成式叙事的小型开源实验。一键 Vercel 自建。
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-3 md:col-start-5">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
题 跋 · II
|
||||
</p>
|
||||
<ul className="font-serif text-clay-700 text-base leading-relaxed space-y-1">
|
||||
<li>文 · 大语言模型</li>
|
||||
<li>图 · 生成式渲染</li>
|
||||
<li>感知 · 视觉解读</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-3 md:col-start-9">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
题 跋 · III
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||
三者各自独立配置 — 任何 OpenAI 兼容端点皆可。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="px-6 md:px-16 pb-10 mt-auto">
|
||||
<div className="hairline-full w-full mb-5" />
|
||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
||||
<span>MMXXVI</span>
|
||||
<span className="num">Ⅰ · Ⅰ</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- typewriter ---------- */
|
||||
|
||||
function Typewriter({ phrases }: { phrases: string[] }) {
|
||||
const [txt, setTxt] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let p = 0;
|
||||
let i = 0;
|
||||
let del = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
setTxt("");
|
||||
const tick = () => {
|
||||
const full = phrases[p] ?? "";
|
||||
if (!del) {
|
||||
i++;
|
||||
setTxt(full.slice(0, i));
|
||||
if (i >= full.length) {
|
||||
del = true;
|
||||
timer = setTimeout(tick, 1700);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(tick, 70);
|
||||
} else {
|
||||
i--;
|
||||
setTxt(full.slice(0, i));
|
||||
if (i <= 0) {
|
||||
del = false;
|
||||
p = (p + 1) % phrases.length;
|
||||
timer = setTimeout(tick, 450);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(tick, 28);
|
||||
}
|
||||
};
|
||||
timer = setTimeout(tick, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phrases]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>{txt}</span>
|
||||
<span className="ip-cursor" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- collapsible category selector ---------- */
|
||||
|
||||
function CategorySelect({
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
open,
|
||||
onToggle,
|
||||
onPick,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
value: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onPick: (i: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={"ip-cat" + (open ? " open" : "")}>
|
||||
<button type="button" className="ip-catbtn" onClick={onToggle}>
|
||||
<span className="ip-catname">{label}</span>
|
||||
<span className="ip-catval">{items[value]}</span>
|
||||
<span className="ip-caret">▾</span>
|
||||
<Frame />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ip-catmenu">
|
||||
{items.map((it, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={"ip-catopt" + (i === value ? " on" : "")}
|
||||
onClick={() => onPick(i)}
|
||||
>
|
||||
{it}
|
||||
</button>
|
||||
))}
|
||||
<Frame />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- style picker modal ---------- */
|
||||
|
||||
function StyleModal({
|
||||
items,
|
||||
value,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
items: string[];
|
||||
value: number;
|
||||
onPick: (i: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [shown, setShown] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const close = () => {
|
||||
setShown(false);
|
||||
setTimeout(onClose, 300);
|
||||
};
|
||||
const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim()));
|
||||
return (
|
||||
<div className={"ip-modal-ov" + (shown ? " show" : "")} onMouseDown={close}>
|
||||
<div className="ip-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Frame />
|
||||
<div className="ip-modal-hd">
|
||||
<div className="ip-modal-ttl">
|
||||
选择绘画风格
|
||||
<span className="ip-modal-sub">默认「自动」· 由模型根据 prompt 判断风格</span>
|
||||
</div>
|
||||
<div className="ip-modal-search">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索风格…"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="si">⌕</span>
|
||||
</div>
|
||||
<button type="button" className="ip-modal-x" onClick={close} aria-label="close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="ip-modal-grid">
|
||||
{list.map(({ name, i }) => (
|
||||
<div
|
||||
key={i}
|
||||
className={"ip-scard" + (i === value ? " on" : "")}
|
||||
onClick={() => {
|
||||
onPick(i);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<div className="sthumb">
|
||||
<ImgGlyph />
|
||||
</div>
|
||||
<div className="sname">{name}</div>
|
||||
</div>
|
||||
))}
|
||||
{list.length === 0 && <div className="ip-noresult">没有匹配的风格</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- scale-to-fit hero canvas ---------- */
|
||||
|
||||
function HeroCanvas({ children }: { children: React.ReactNode }) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const fit = () => {
|
||||
const stage = stageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!stage || !canvas) return;
|
||||
// scale to fit width; clamp so very wide screens don't pixel-up the design
|
||||
const s = Math.min(1, stage.clientWidth / HERO_CANVAS_W);
|
||||
canvas.style.transform = `scale(${s})`;
|
||||
stage.style.height = HERO_CANVAS_H * s + "px";
|
||||
};
|
||||
fit();
|
||||
window.addEventListener("resize", fit);
|
||||
const ro = new ResizeObserver(fit);
|
||||
if (stageRef.current) ro.observe(stageRef.current);
|
||||
return () => {
|
||||
window.removeEventListener("resize", fit);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
style={{ position: "relative", width: "100%", overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: HERO_CANVAS_W,
|
||||
height: HERO_CANVAS_H,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- page ---------- */
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [sel, setSel] = useState<number[]>(OPTS.map((o) => o.defaultIndex ?? 0));
|
||||
const [open, setOpen] = useState<number>(-1);
|
||||
const [styleOpen, setStyleOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const styleRow = OPTS.findIndex((o) => o.modal);
|
||||
const genderIndex = sel[0] ?? 0;
|
||||
const gender = (OPTS[0]!.items[genderIndex] as "男性向" | "女性向") ?? "男性向";
|
||||
const phrases = EXAMPLE_PHRASES[gender];
|
||||
|
||||
/* close any open dropdown on outside click */
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target?.closest?.(".ip-cat")) setOpen(-1);
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const start = () => {
|
||||
const userPrompt = prompt.trim();
|
||||
const artStyle = OPTS[1]!.items[sel[1] ?? 0]!;
|
||||
const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!;
|
||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
||||
const pace = OPTS[4]!.items[sel[4] ?? 1]!;
|
||||
|
||||
const worldSetting = [
|
||||
`这是一款面向【${gender}】观众的 AI 交互剧情游戏。`,
|
||||
`剧情风格:${plotStyle}。内容节奏:${pace}。`,
|
||||
userPrompt ? `玩家给出的故事种子:「${userPrompt}」。` : "",
|
||||
`请依据上述设定,以极致的戏剧张力与细腻的情感起伏,为玩家编织精彩的故事分支与对话。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const styleMap: Record<string, string> = {
|
||||
二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。",
|
||||
吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。",
|
||||
真实系: "真实电影感,柔和自然光照,胶片颗粒。",
|
||||
超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。",
|
||||
水彩: "水彩插画,湿润晕染笔触,纸纹底色。",
|
||||
像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。",
|
||||
日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。",
|
||||
"3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
|
||||
蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
|
||||
玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
|
||||
国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
|
||||
赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
|
||||
};
|
||||
// 「自动」→ fall back to 二次元 (project default). Plain prompts like
|
||||
// "由模型自动判断画风" are not understood by FLUX — it just paints them
|
||||
// literally, so we'd rather lock in a sensible default.
|
||||
const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle;
|
||||
const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
|
||||
sessionStorage.setItem(
|
||||
"yume:custom",
|
||||
JSON.stringify({ worldSetting, styleGuide, audioEnabled }),
|
||||
);
|
||||
router.push("/play?custom=1");
|
||||
};
|
||||
|
||||
const onCardClick = (seed?: string) => {
|
||||
if (seed) setPrompt(seed);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
{/* ================== HERO (scale-to-fit 1900×980 canvas) ================== */}
|
||||
<HeroCanvas>
|
||||
{/* tagline */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 172,
|
||||
transform: "translateX(-50%)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<div className="ip-tagline" style={{ fontSize: 33 }}>
|
||||
今天想穿越到什么故事?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* prompt bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 250,
|
||||
transform: "translateX(-50%)",
|
||||
width: 1100,
|
||||
height: 68,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="ip-bar"
|
||||
style={{ width: 1100, height: 68 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
start();
|
||||
}}
|
||||
>
|
||||
<div className="ip-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder=" "
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!prompt && (
|
||||
<div className="ph">
|
||||
<Typewriter phrases={phrases} />
|
||||
</div>
|
||||
)}
|
||||
<Frame />
|
||||
</div>
|
||||
<button type="submit" className="ip-start">
|
||||
<span>开 始</span>
|
||||
<Frame />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* category selectors */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 352,
|
||||
transform: "translateX(-50%)",
|
||||
width: 1180,
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{OPTS.map((o, r) => (
|
||||
<CategorySelect
|
||||
key={r}
|
||||
label={o.label}
|
||||
items={o.items}
|
||||
value={sel[r] ?? 0}
|
||||
open={open === r}
|
||||
onToggle={() => {
|
||||
if (o.modal) {
|
||||
setStyleOpen(true);
|
||||
} else {
|
||||
setOpen(open === r ? -1 : r);
|
||||
}
|
||||
}}
|
||||
onPick={(i) => {
|
||||
setSel((s) => s.map((v, j) => (j === r ? i : v)));
|
||||
setOpen(-1);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* hero scattered cards — content switches by 性向 */}
|
||||
{HERO_SLOTS.map((slot, i) => {
|
||||
const content = HERO_CONTENT[gender][i]!;
|
||||
const suffix = gender === "女性向" ? "_f" : "";
|
||||
return (
|
||||
<div
|
||||
key={`${gender}-${i}`}
|
||||
className="ip-card"
|
||||
style={{
|
||||
left: slot.x,
|
||||
top: slot.y,
|
||||
width: slot.w,
|
||||
height: slot.h,
|
||||
transform: `rotate(calc(${slot.rot}deg * var(--jit)))`,
|
||||
}}
|
||||
onClick={() => onCardClick(content.outline)}
|
||||
>
|
||||
<CardBody
|
||||
title={content.title}
|
||||
outline={content.outline}
|
||||
image={`/home/hero${i}${suffix}.webp`}
|
||||
/>
|
||||
<Frame />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</HeroCanvas>
|
||||
|
||||
{/* ================== SCROLL HINT + GALLERY ================== */}
|
||||
<div className="ip-sectionnote">
|
||||
<span className="arr">↓</span>
|
||||
继续下滑 · 加载更多示例卡片
|
||||
</div>
|
||||
|
||||
<div className="ip-gallery">
|
||||
{GALLERY.map((g, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ip-card gcard"
|
||||
style={{ height: g.h, ["--gr" as string]: g.rot + "deg" } as React.CSSProperties}
|
||||
onClick={() => onCardClick(g.outline)}
|
||||
>
|
||||
<CardBody
|
||||
title={g.title}
|
||||
outline={g.outline}
|
||||
image={`/home/gallery${i}.webp`}
|
||||
/>
|
||||
<Frame />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ================== PROJECT INTRO ================== */}
|
||||
<div className="ip-intro">
|
||||
<div className="kicker">INFIPLOT · AI 实时交互剧情游戏 · Demo</div>
|
||||
|
||||
<p>
|
||||
<b>InfiPlot</b> 是一款用 AI 实时生成内容的交互式剧情游戏 —— 图片、语音与剧情分支都在游玩过程中即时生成。我们希望探索多模态模型在「直接生成图片、视频」这类
|
||||
one-shot 能力之外,更多的可能性。
|
||||
</p>
|
||||
<p>
|
||||
我们希望通过这个页面,与<b>赞助商</b>、未来的<b>团队成员</b>以及<b>内测用户</b>建立联系。
|
||||
</p>
|
||||
|
||||
<div className="label">团 队</div>
|
||||
<p>
|
||||
我们是一群来自<b>清华大学</b>等海内外高校、充满激情的年轻人,目前仍处于早期阶段。产品还在打磨,团队也在<b>招募成员</b>。
|
||||
</p>
|
||||
|
||||
<div className="label">加 入 / 合 作</div>
|
||||
<p>
|
||||
如有意加入团队,请将简历发送至 <span className="mail">hi@infiplot.com</span>
|
||||
</p>
|
||||
|
||||
<div className="label">联 系 方 式</div>
|
||||
<p>
|
||||
邮箱 <span className="mail">hi@infiplot.com</span> · Founder X / Twitter <b>@yzh_im</b>
|
||||
</p>
|
||||
|
||||
<div className="label">内 测 用 户 群</div>
|
||||
<p>
|
||||
群二维码 / 邀请链接 <span style={{ color: "var(--ink-faint)" }}>(待补充)</span>
|
||||
</p>
|
||||
|
||||
<p style={{ fontSize: 13, color: "var(--ink-faint)", lineHeight: 1.75, marginTop: 32 }}>
|
||||
内测期间本产品可免费使用,但稳定性可能会随并发用户数量而有波动。欢迎赞助商联系我们,提供更多算力资源和商讨长期合作事宜。
|
||||
<br />
|
||||
内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
|
||||
<br />
|
||||
AI 生成的内容不代表本团队立场。
|
||||
</p>
|
||||
<Frame />
|
||||
</div>
|
||||
|
||||
{styleOpen && styleRow >= 0 && (
|
||||
<StyleModal
|
||||
items={OPTS[styleRow]!.items}
|
||||
value={sel[styleRow] ?? 0}
|
||||
onPick={(i) => setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
|
||||
onClose={() => setStyleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,6 +262,10 @@ function PlayInner() {
|
||||
// changes so stale in-flight requests can't poison the new scene's map
|
||||
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||||
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
||||
// User-toggled "语音配音" from the homepage. Defaults to true for back-compat
|
||||
// when older sessionStorage payloads omit the field. Mutated once in
|
||||
// bootstrap and read by fetchBeatAudio to early-return without any /api call.
|
||||
const audioEnabledRef = useRef<boolean>(true);
|
||||
|
||||
// Mirrors for use inside async handlers (closure-stable)
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
@@ -317,6 +321,7 @@ function PlayInner() {
|
||||
sess: Session,
|
||||
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
||||
): Promise<void> => {
|
||||
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
|
||||
if (!beat.speaker || !beat.line) return;
|
||||
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
||||
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
||||
@@ -450,7 +455,14 @@ function PlayInner() {
|
||||
const stored = sessionStorage.getItem("yume:custom");
|
||||
if (stored) {
|
||||
try {
|
||||
payload = JSON.parse(stored);
|
||||
const parsed = JSON.parse(stored) as {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
audioEnabled?: boolean;
|
||||
};
|
||||
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
||||
// default true for older payloads that omit the flag
|
||||
audioEnabledRef.current = parsed.audioEnabled !== false;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-off generator: produces 23 AI cards (7 hero + 16 gallery) for the
|
||||
* InfiPlot homepage via Runware FLUX.2 and writes them as PNGs under
|
||||
* apps/web/public/home/.
|
||||
*
|
||||
* Reads IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL from apps/web/.env.local.
|
||||
*
|
||||
* Run once:
|
||||
* node apps/web/scripts/generate-home-images.mjs
|
||||
*
|
||||
* Idempotent: skips any PNG that already exists. Pass --force to regenerate.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const WEB_ROOT = resolve(__dirname, "..");
|
||||
const ENV_FILE = resolve(WEB_ROOT, ".env.local");
|
||||
const OUT_DIR = resolve(WEB_ROOT, "public", "home");
|
||||
|
||||
const FORCE = process.argv.includes("--force");
|
||||
|
||||
/* ---------- env loading (tiny .env parser) ---------- */
|
||||
function loadEnv(path) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
const env = {};
|
||||
for (const raw of txt.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const k = line.slice(0, eq).trim();
|
||||
let v = line.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
env[k] = v;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = loadEnv(ENV_FILE);
|
||||
const BASE_URL = env.IMAGE_BASE_URL;
|
||||
const API_KEY = env.IMAGE_API_KEY;
|
||||
const MODEL = env.IMAGE_MODEL;
|
||||
if (!BASE_URL || !API_KEY || !MODEL) {
|
||||
console.error("Missing IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL in", ENV_FILE);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!BASE_URL.includes("runware.ai")) {
|
||||
console.error("This script assumes Runware. Got:", BASE_URL);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* ---------- prompts ---------- */
|
||||
|
||||
const BASE_QUALITY =
|
||||
"masterpiece, best quality, highly detailed, cinematic lighting, soft warm color grading, intricate background, no text, no watermark";
|
||||
|
||||
// 7 hero cards — varied flagship moods that showcase the platform's range
|
||||
const HERO = [
|
||||
{
|
||||
name: "hero0",
|
||||
prompt:
|
||||
"anime visual novel cover art, two high school students standing under cherry blossom petals at dusk, warm golden sunset light, soft watercolor texture, japanese galgame illustration, widescreen composition",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero1",
|
||||
prompt:
|
||||
"post-apocalyptic wasteland anime, lone scavenger silhouette against rusted mecha mountain, golden dust storm sweeping across the dunes, cinematic widescreen, anime concept art, dramatic backlight",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero2",
|
||||
prompt:
|
||||
"anime xianxia cultivator boy in flowing white robes standing on a floating mountain peak above a sea of clouds, vermillion banners fluttering, vertical poster composition, chinese mythology, galgame illustration",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "hero3",
|
||||
prompt:
|
||||
"anime visual novel scene, southern chinese small town in june rain, a transfer student looking back from a rainy classroom window, ceiling fan in background, soft warm afternoon tones, slice of life galgame illustration",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
{
|
||||
name: "hero4",
|
||||
prompt:
|
||||
"cyberpunk anime portrait, amnesiac detective standing in neon-soaked rainy alley of an east-asian metropolis in 2087, holographic signs reflecting on wet pavement, vertical composition, blade runner palette, anime illustration",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "hero5",
|
||||
prompt:
|
||||
"anime mystery scene, late-night high school library underground chamber, flickering candlelight, a class president kneeling before a glowing rune circle on the stone floor, gothic galgame style, mysterious teal-green glow",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero6",
|
||||
prompt:
|
||||
"anime isekai cathedral scene, silver-haired holy maiden with tearful eyes kneeling before a glowing magic summoning circle, golden cathedral light streaming through stained glass, summoned hero just appearing in modern school uniform, warm galgame illustration",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
];
|
||||
|
||||
// 7 female-oriented hero cards — same slot aspect ratios as HERO above,
|
||||
// otome / josei / xianxia / cyberpunk romance angles
|
||||
const HERO_F = [
|
||||
{
|
||||
name: "hero0_f",
|
||||
prompt:
|
||||
"anime josei otome game illustration, beautiful female protagonist in ornate eastern hanfu silk robes, behind her a tall stoic regent prince in dark embroidered robes leaning down to clasp a red jade bracelet on her wrist, ancient chinese palace interior, soft candlelight, romantic widescreen composition",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero1_f",
|
||||
prompt:
|
||||
"anime modern romance scene, young woman in pajamas sitting on a bed at dawn, golden light through curtains, looking at her phone in shock as if she has just been pulled back in time, soft warm tones, melancholic otome illustration, widescreen",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero2_f",
|
||||
prompt:
|
||||
"anime villainess otome game character, beautiful young noblewoman with elaborate golden ringlet hair and crimson ballgown, standing alone in a baroque royal academy ballroom while other noble girls glare from the background, dramatic chandelier light, vertical poster composition, otome game cover art",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "hero3_f",
|
||||
prompt:
|
||||
"anime visual novel scene, female high school transfer student standing on a rainy southern chinese town rooftop, sharing her umbrella with a moody boy reading poetry on the railing, soft warm afternoon palette, slice of life otome illustration",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
{
|
||||
name: "hero4_f",
|
||||
prompt:
|
||||
"anime josei coronation scene, beautiful young empress in ornate ceremonial robes seated on a high eastern throne, head turned to glance at a handsome attendant standing in the shadowed pillars below, vertical composition, opulent silks and gold, otome game illustration",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "hero5_f",
|
||||
prompt:
|
||||
"anime wuxia swordswoman in flowing light hanfu, jade hairpin, white sword raised mid-stance, cherry blossoms swirling around her, mountain pavilion in the background at golden hour, dynamic widescreen otome wuxia illustration",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "hero6_f",
|
||||
prompt:
|
||||
"anime visual novel scene, female high school student standing on a sunset rooftop looking up at a tall handsome senior in school uniform, warm orange sky, golden hour, romantic galgame otome cover art, widescreen",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
];
|
||||
|
||||
// 16 gallery cards — broader sweep of genres / moods showcased by the platform
|
||||
const GALLERY = [
|
||||
{
|
||||
name: "gallery0",
|
||||
prompt:
|
||||
"anime girl in summer yukata watching fireworks at a japanese festival night, warm bokeh lanterns, vertical composition, soft watercolor, slice of life galgame",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery1",
|
||||
prompt:
|
||||
"cyberpunk neon city skyline at rainy night, flying vehicles, holographic billboards in chinese characters, anime widescreen, cinematic",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery2",
|
||||
prompt:
|
||||
"anime two students standing on empty rural train platform after school, golden hour, slice of life galgame illustration, cinematic widescreen, warm tones",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
{
|
||||
name: "gallery3",
|
||||
prompt:
|
||||
"anime mage girl in star-embroidered robes casting starlight spell, ancient fantasy library, vertical composition, magical particles, painterly illustration",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery4",
|
||||
prompt:
|
||||
"anime mecha pilot girl strapped in cockpit, holographic interfaces around her, dramatic red emergency lighting, intense expression, mecha anime style",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery5",
|
||||
prompt:
|
||||
"anime detective girl in long trench coat under a flickering streetlamp at midnight, noir mood, vertical composition, rain mist, cinematic anime",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery6",
|
||||
prompt:
|
||||
"anime cyberpunk couple sharing a quiet moment in a neon-lit rainy alley, holographic umbrella, electric blue and pink reflections, romantic galgame illustration",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
{
|
||||
name: "gallery7",
|
||||
prompt:
|
||||
"anime sword duel between two xianxia cultivators in a bamboo grove, motion blur on swords, falling bamboo leaves, dynamic action composition",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery8",
|
||||
prompt:
|
||||
"anime princess in ornate eastern gown seated on an ancient carved throne, candlelight, intricate background tapestries, vertical poster composition, fantasy galgame",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery9",
|
||||
prompt:
|
||||
"anime classroom afternoon, sun streaming through windows onto empty desks, a single uniformed student writing in a notebook, slice of life watercolor, nostalgic",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery10",
|
||||
prompt:
|
||||
"anime girl reading a folded letter under a cherry blossom tree, melancholic expression, petals drifting, soft warm watercolor, slice of life galgame",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
{
|
||||
name: "gallery11",
|
||||
prompt:
|
||||
"anime moon goddess descending from a starlit sky, silver hair flowing, ethereal aurora glow, dreamy painterly illustration, vertical composition",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery12",
|
||||
prompt:
|
||||
"anime samurai standing alone under a blood red full moon, sakura petals carried on the wind, katana drawn, dramatic backlight, cinematic widescreen",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery13",
|
||||
prompt:
|
||||
"anime witch girl brewing a glowing potion in a candlelit forest hut, hanging dried herbs, magical sparks rising from the cauldron, vertical composition",
|
||||
w: 768,
|
||||
h: 1024,
|
||||
},
|
||||
{
|
||||
name: "gallery14",
|
||||
prompt:
|
||||
"anime beach summer scene, two girlfriends sitting on the sand watching a pink-orange sunset, gentle waves, slice of life galgame illustration",
|
||||
w: 1024,
|
||||
h: 640,
|
||||
},
|
||||
{
|
||||
name: "gallery15",
|
||||
prompt:
|
||||
"anime hacker girl in a dim apartment surrounded by glowing screens, neon cyan reflections on her face, intense focus, cyberpunk galgame style",
|
||||
w: 1024,
|
||||
h: 832,
|
||||
},
|
||||
];
|
||||
|
||||
const ALL = [...HERO, ...HERO_F, ...GALLERY];
|
||||
|
||||
/* ---------- Runware caller ---------- */
|
||||
|
||||
async function generate({ prompt, w, h }) {
|
||||
const body = [
|
||||
{
|
||||
taskType: "imageInference",
|
||||
taskUUID: crypto.randomUUID(),
|
||||
model: MODEL,
|
||||
positivePrompt: `${prompt}, ${BASE_QUALITY}`,
|
||||
width: w,
|
||||
height: h,
|
||||
steps: 4,
|
||||
CFGScale: 3.5,
|
||||
numberResults: 1,
|
||||
outputType: "base64Data",
|
||||
outputFormat: "PNG",
|
||||
},
|
||||
];
|
||||
const res = await fetch(BASE_URL.replace(/\/$/, ""), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
if (json.errors?.length) {
|
||||
const e = json.errors[0];
|
||||
throw new Error(`Runware [${e.code ?? "?"}]: ${e.message ?? "no msg"}`);
|
||||
}
|
||||
const b64 = json.data?.[0]?.imageBase64Data;
|
||||
if (!b64) throw new Error(`No image data: ${text.slice(0, 200)}`);
|
||||
return Buffer.from(b64, "base64");
|
||||
}
|
||||
|
||||
/* ---------- main loop ---------- */
|
||||
|
||||
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const total = ALL.length;
|
||||
let done = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
const t0 = Date.now();
|
||||
|
||||
console.log(`[gen] ${total} cards → ${OUT_DIR}`);
|
||||
|
||||
for (const card of ALL) {
|
||||
const out = resolve(OUT_DIR, `${card.name}.png`);
|
||||
const webpOut = resolve(OUT_DIR, `${card.name}.webp`);
|
||||
if (!FORCE && (existsSync(out) || existsSync(webpOut))) {
|
||||
const path = existsSync(out) ? out : webpOut;
|
||||
const size = statSync(path).size;
|
||||
if (size > 1024) {
|
||||
skipped++;
|
||||
done++;
|
||||
console.log(`[${done}/${total}] skip ${card.name} (${size} B)`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const label = `[${++done}/${total}] ${card.name}`;
|
||||
process.stdout.write(`${label} … `);
|
||||
const t = Date.now();
|
||||
try {
|
||||
const buf = await generate(card);
|
||||
writeFileSync(out, buf);
|
||||
process.stdout.write(`ok ${buf.length} B in ${Math.round((Date.now() - t) / 100) / 10}s\n`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
process.stdout.write(`FAIL: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n[gen] done in ${Math.round((Date.now() - t0) / 1000)}s — generated ${
|
||||
done - skipped - failed
|
||||
} / skipped ${skipped} / failed ${failed}`,
|
||||
);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compresses the freshly generated apps/web/public/home/*.png into much
|
||||
* smaller .webp files alongside them, then deletes the originals.
|
||||
* Output webps target ~1200px on the long edge and quality 78.
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { resolve, dirname, extname, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DIR = resolve(__dirname, "..", "public", "home");
|
||||
|
||||
const MAX_EDGE = 1200;
|
||||
const QUALITY = 78;
|
||||
|
||||
const files = readdirSync(DIR).filter((f) => f.toLowerCase().endsWith(".png"));
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const inPath = resolve(DIR, f);
|
||||
const outPath = resolve(DIR, basename(f, extname(f)) + ".webp");
|
||||
const inSize = statSync(inPath).size;
|
||||
totalIn += inSize;
|
||||
|
||||
const img = sharp(inPath);
|
||||
const meta = await img.metadata();
|
||||
const longEdge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
||||
const resized = longEdge > MAX_EDGE ? img.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined, height: meta.height > meta.width ? MAX_EDGE : undefined }) : img;
|
||||
await resized.webp({ quality: QUALITY, effort: 5 }).toFile(outPath);
|
||||
const outSize = statSync(outPath).size;
|
||||
totalOut += outSize;
|
||||
console.log(`${f.padEnd(16)} ${(inSize / 1024).toFixed(0).padStart(5)} KB → ${(outSize / 1024).toFixed(0).padStart(4)} KB`);
|
||||
unlinkSync(inPath);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nTotal: ${(totalIn / 1024 / 1024).toFixed(1)} MB → ${(totalOut / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||