Files
AiVideo/server/public/index.html
2026-04-14 12:05:56 +08:00

2277 lines
71 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI Video Workspace</title>
<style>
:root {
--bg: #f6f8fc;
--panel: #eef2f8;
--surface: #ffffff;
--soft: #f3f6fb;
--line: #dce3ef;
--text: #1f2329;
--muted: #6d7482;
--blue: #1a73e8;
--blue-soft: #e8f0fe;
--timeline-h: 300px;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: "Google Sans", "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: radial-gradient(circle at 10% -20%, #ffffff 0%, var(--bg) 42%, #eff3f9 100%);
}
button,
input,
textarea,
select {
font: inherit;
}
.app {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr) 340px 82px;
}
.stage {
min-width: 0;
display: grid;
grid-template-rows: 50px minmax(0, 1fr) var(--timeline-h);
border-right: 1px solid var(--line);
}
.topbar {
border-bottom: 1px solid var(--line);
background: #fff;
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px;
}
.icon-btn {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: #4e5562;
cursor: pointer;
}
.icon-btn:hover {
background: #f1f4f9;
}
.icon-btn.active {
border-color: #bfd1f7;
background: var(--blue-soft);
color: #1458bf;
}
.divider {
width: 1px;
height: 18px;
background: var(--line);
margin: 0 2px;
}
.fit-pill {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
color: #525a67;
}
.canvas-zone {
min-height: 0;
display: grid;
place-items: center;
padding: 20px 16px 8px;
}
.preview-shell {
width: min(100%, 980px);
display: grid;
gap: 12px;
justify-items: center;
}
.preview-canvas {
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid #c6cfde;
border-radius: 2px;
position: relative;
overflow: hidden;
background: #0e1118;
box-shadow: 0 8px 22px rgba(19, 32, 58, 0.08);
}
.preview-video,
.preview-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 160ms ease;
}
.preview-video.active,
.preview-image.active {
opacity: 1;
}
.preview-overlay {
position: absolute;
inset: auto 18px 16px;
padding: 12px;
border-radius: 10px;
background: rgba(12, 18, 30, 0.5);
color: #fff;
backdrop-filter: blur(2px);
z-index: 3;
}
.preview-title {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.preview-sub {
margin: 4px 0 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
}
.sizer {
width: 48px;
height: 4px;
border-radius: 999px;
background: #8c929a;
}
.timeline-wrap {
margin: 0 12px 12px;
background: var(--panel);
border-radius: 16px;
border: 1px solid #e2e8f3;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
padding: 10px 12px;
min-height: 0;
}
.transport {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
}
.left-tools,
.center-tools,
.right-tools {
display: flex;
align-items: center;
gap: 10px;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
padding: 7px 11px;
font-size: 12px;
color: #505967;
}
.chip.toggle.active {
border-color: #b8cdf7;
background: #e8f0fe;
color: #1a56c5;
}
.play {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: var(--blue);
color: #fff;
font-size: 14px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(30, 89, 183, 0.3);
}
.time {
font-size: 26px;
line-height: 1;
color: #1e56be;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.time small {
font-size: 15px;
color: #4e5664;
}
.zoom-group {
display: flex;
align-items: center;
gap: 8px;
color: #4d5562;
}
input[type="range"] {
width: 132px;
accent-color: #5e646d;
}
.timeline-body {
margin-top: 10px;
min-height: 0;
display: grid;
grid-template-columns: 102px 1fr auto;
gap: 10px;
align-items: start;
}
.track-labels {
display: flex;
flex-direction: column;
gap: 8px;
}
.track-label-spacer {
height: 30px;
}
.track-label {
border: 1px solid #d6ddeb;
border-radius: 10px;
background: #fff;
display: grid;
place-items: center;
font-size: 12px;
color: #5e6674;
font-weight: 600;
height: 72px;
}
.track-label.collapsed {
height: 36px;
}
.timeline-scroll {
min-width: 0;
overflow: auto;
border: 1px solid #dbe2ee;
border-radius: 10px;
background: #f9fbff;
position: relative;
}
.timeline-inner {
position: relative;
min-height: 246px;
}
.ruler {
height: 30px;
border-bottom: 1px solid #d9e0ec;
background: linear-gradient(180deg, #fafdff 0%, #f3f7fd 100%);
position: relative;
user-select: none;
cursor: ew-resize;
}
.tick {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #ccd4e2;
}
.tick.major {
background: #aeb9cd;
}
.tick-label {
position: absolute;
top: 2px;
left: 4px;
font-size: 10px;
color: #707a8b;
font-variant-numeric: tabular-nums;
}
.tracks {
position: relative;
height: 216px;
}
.track-row {
position: relative;
height: 72px;
border-bottom: 1px solid #e0e6f1;
}
.track-row:last-child {
border-bottom: none;
}
.track-row.collapsed {
height: 36px;
}
.grid-line {
position: absolute;
top: 30px;
bottom: 0;
width: 1px;
background: rgba(163, 177, 199, 0.33);
pointer-events: none;
}
.clip {
position: absolute;
top: 10px;
height: 52px;
border-radius: 10px;
border: 2px solid transparent;
box-shadow: 0 3px 9px rgba(18, 36, 70, 0.1);
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px;
font-size: 12px;
color: #1f2530;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: grab;
user-select: none;
}
.clip.video {
background: linear-gradient(135deg, #e6f0ff 0%, #d2e3ff 100%);
}
.clip.audio {
background: linear-gradient(135deg, #f4efff 0%, #e4dbff 100%);
}
.clip.caption {
background: linear-gradient(135deg, #fff5df 0%, #ffe5a9 100%);
}
.clip.active {
border-color: #4e86ec;
}
.clip.selected {
border-color: var(--blue);
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.16), 0 6px 16px rgba(31, 84, 178, 0.2);
}
.clip.conflict {
border-color: #d93025;
box-shadow: 0 0 0 2px rgba(217, 48, 37, 0.15), 0 4px 12px rgba(120, 32, 22, 0.2);
}
.track-row.collapsed .clip {
top: 6px;
height: 24px;
padding: 0 8px;
}
.track-row.collapsed .clip .clip-handle {
top: 2px;
bottom: 2px;
}
.clip-handle {
position: absolute;
top: 4px;
bottom: 4px;
width: 8px;
border-radius: 6px;
background: rgba(13, 37, 79, 0.36);
cursor: ew-resize;
}
.clip-handle.left {
left: 3px;
}
.clip-handle.right {
right: 3px;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--blue);
pointer-events: none;
z-index: 8;
}
.playhead::before {
content: "";
position: absolute;
top: -3px;
left: -5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--blue);
}
.plus {
align-self: end;
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid #c2d3f2;
background: #dce9ff;
color: #1f57b4;
box-shadow: 0 5px 12px rgba(28, 78, 166, 0.22);
cursor: pointer;
}
.panel {
border-right: 1px solid var(--line);
background: #e9edf4;
padding: 12px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.version {
border: 1px solid #d9dee8;
border-radius: 999px;
background: #f8f9fb;
padding: 4px 10px;
font-size: 12px;
color: #505867;
}
.card-section {
background: transparent;
border: none;
border-radius: 14px;
padding: 0;
}
.gen-card {
background: transparent;
}
.gen-mode {
width: 100%;
text-align: left;
padding: 10px 38px 10px 14px;
border: 1px solid #d9dfea;
border-radius: 999px;
background: #f7f8fa;
color: #4c5361;
font-weight: 700;
appearance: none;
-webkit-appearance: none;
background-image: linear-gradient(45deg, transparent 50%, #667085 50%), linear-gradient(135deg, #667085 50%, transparent 50%);
background-position: calc(100% - 18px) calc(50% - 2px), calc(100% - 13px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.gen-mode-desc {
margin: 7px 4px 2px;
font-size: 12px;
color: #6f7683;
}
.gen-prompt {
margin-top: 8px;
width: 100%;
min-height: 170px;
border: 1px solid #d9dfea;
border-radius: 18px;
background: #f7f8fa;
color: #6a7079;
padding: 14px;
resize: none;
line-height: 1.4;
}
.gen-meta {
margin-top: 8px;
}
.small-pill {
border: 1px solid #d4d9e2;
border-radius: 999px;
background: #dde2ea;
color: #49515d;
font-weight: 700;
padding: 5px 12px;
}
.ratio-select {
border: 1px solid #d4d9e2;
border-radius: 999px;
background: #dde2ea;
color: #49515d;
font-weight: 700;
padding: 5px 30px 5px 12px;
appearance: none;
-webkit-appearance: none;
background-image: linear-gradient(45deg, transparent 50%, #6c7382 50%), linear-gradient(135deg, #6c7382 50%, transparent 50%);
background-position: calc(100% - 16px) calc(50% - 2px), calc(100% - 11px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.gen-actions {
margin-top: 8px;
}
.gen-btn {
width: 100%;
border: none;
border-radius: 999px;
background: #c5cad2;
color: #8c929b;
height: 42px;
cursor: pointer;
font-weight: 600;
}
.gen-btn.ready {
background: var(--blue);
color: #fff;
}
.generated-wrap {
margin-top: 10px;
border: 1px dashed #c7d2e7;
border-radius: 14px;
background: rgba(255, 255, 255, 0.45);
padding: 10px;
}
.generated-wrap.collapsed {
display: none;
}
.generated-title {
font-size: 12px;
font-weight: 700;
color: #475163;
margin-bottom: 8px;
}
.generated-list {
display: grid;
gap: 10px;
max-height: 240px;
overflow: auto;
}
.generated-item {
background: #fff;
border: 1px solid #d7deea;
border-radius: 12px;
padding: 8px;
}
.generated-video {
width: 100%;
height: 110px;
border-radius: 8px;
background: #000;
object-fit: cover;
}
.generated-meta {
margin-top: 6px;
font-size: 12px;
color: #586275;
}
.generated-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.generated-actions .btn {
flex: 1;
font-size: 12px;
}
.progress-card {
margin-top: 10px;
border-radius: 14px;
min-height: 122px;
background: #aec6e8;
padding: 14px;
display: grid;
grid-template-rows: auto 1fr auto;
cursor: pointer;
display: none;
}
.progress-card.active {
display: grid;
}
.progress-percent {
font-size: 34px;
font-weight: 700;
color: #2d3a51;
}
.progress-hint {
align-self: center;
justify-self: center;
color: rgba(52, 66, 89, 0.72);
font-size: 12px;
}
.progress-action {
justify-self: end;
border: none;
background: transparent;
color: #0f52bf;
font-size: 30px;
cursor: pointer;
}
@media (max-width: 1600px) {
.progress-percent {
font-size: 34px;
}
.progress-action {
font-size: 14px;
}
}
.field {
display: grid;
gap: 5px;
margin-top: 8px;
}
.field label {
font-size: 12px;
color: #677081;
}
.input,
.text,
.select {
width: 100%;
border: 1px solid #d7dfec;
border-radius: 10px;
padding: 8px 10px;
background: #fff;
}
.text {
min-height: 76px;
resize: vertical;
}
.row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ghost-box {
height: 74px;
border: 1px dashed #9fb9ed;
border-radius: 12px;
background: rgba(223, 232, 246, 0.55);
display: grid;
place-items: center;
color: #8ea8d9;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-align: center;
gap: 4px;
}
.ghost-box .up-icon {
font-size: 18px;
line-height: 1;
}
.ghost-box.has-file {
border-style: solid;
border-color: #7ba3eb;
background: #e9f1ff;
color: #3d64aa;
}
.ghost-box.hidden {
display: none;
}
.btn-row {
display: flex;
gap: 8px;
}
.btn {
border: 1px solid #cfd9ea;
background: #fff;
color: #4d5564;
border-radius: 9px;
padding: 7px 10px;
cursor: pointer;
}
.btn.primary {
background: var(--blue);
border-color: var(--blue);
color: #fff;
}
.status {
margin-top: 8px;
padding: 7px 9px;
border: 1px solid #d6e0f2;
border-radius: 8px;
background: #f8fbff;
color: #5b6680;
font-size: 12px;
}
.divider-line {
display: none;
}
.sub-title {
display: none;
}
.rail {
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40px;
gap: 8px;
}
.rail-tool {
width: 68px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: #4f5560;
padding: 6px 4px;
display: grid;
justify-items: center;
gap: 4px;
cursor: pointer;
}
.rail-tool.active {
border-color: #c6d8fc;
background: var(--blue-soft);
color: #1a56c6;
}
.rail-icon {
font-size: 14px;
}
.rail-label {
font-size: 12px;
}
@media (max-width: 1320px) {
.app {
grid-template-columns: 1fr 82px;
}
.panel {
display: none;
}
}
@media (max-width: 900px) {
.stage {
grid-template-rows: 50px minmax(0, 1fr) 340px;
}
.timeline-body {
grid-template-columns: 1fr;
}
.track-labels {
flex-direction: row;
}
.track-labels > :first-child {
display: none;
}
.track-label {
flex: 1;
height: 32px;
}
}
</style>
</head>
<body>
<div class="app">
<main class="stage">
<header class="topbar">
<button class="icon-btn"></button>
<button class="icon-btn"></button>
<button class="icon-btn"></button>
<button class="icon-btn"></button>
<button class="icon-btn"></button>
<div class="divider"></div>
<button class="icon-btn active"></button>
<button class="icon-btn"></button>
<button class="icon-btn"></button>
<div class="divider"></div>
<button class="fit-pill">Fit ▾</button>
</header>
<section class="canvas-zone">
<div class="preview-shell">
<div class="preview-canvas">
<video class="preview-video" id="previewVideo" muted playsinline preload="metadata"></video>
<img class="preview-image" id="previewImage" src="/assets/demo.jpg" alt="preview" />
<div class="preview-overlay">
<p class="preview-title" id="previewTitle">镜头预览</p>
<p class="preview-sub" id="previewSub">拖动时间线以查看对应片段</p>
</div>
</div>
<div class="sizer"></div>
</div>
</section>
<section class="timeline-wrap">
<div class="transport">
<div class="left-tools">
<button class="chip">Show timing ▾</button>
<button class="chip toggle active" id="snapBtn">Snap 0.1s</button>
<button class="chip toggle" id="foldAudioBtn">Fold Audio</button>
<button class="chip toggle" id="videoOnlyBtn">Video Only</button>
</div>
<div class="center-tools">
<button class="play" id="playBtn"></button>
<span class="time" id="timeText">00:00.0 <small>/ 00:10.0</small></span>
</div>
<div class="right-tools">
<button class="chip" id="undoBtn">Undo</button>
<button class="chip" id="redoBtn">Redo</button>
<div class="zoom-group">
<input id="zoomRange" type="range" min="50" max="220" value="100" />
<span id="zoomText">100%</span>
</div>
</div>
</div>
<div class="timeline-body">
<div class="track-labels">
<div class="track-label-spacer"></div>
<div class="track-label" data-track="video">Video</div>
<div class="track-label" data-track="audio">Audio</div>
<div class="track-label" data-track="caption">Caption</div>
</div>
<div class="timeline-scroll" id="timelineScroll">
<div class="timeline-inner" id="timelineInner">
<div class="ruler" id="ruler"></div>
<div class="tracks" id="tracks">
<div class="track-row" data-track="video"></div>
<div class="track-row" data-track="audio"></div>
<div class="track-row" data-track="caption"></div>
<div class="playhead" id="playhead"></div>
</div>
</div>
</div>
<button class="plus" id="addClipBtn" title="添加镜头"></button>
</div>
</section>
</main>
<aside class="panel">
<div class="panel-head">
<div class="panel-title">🎬 AI video clip</div>
<div>
<span class="version">Veo 3.1 ▾</span>
<button class="icon-btn" title="close"></button>
</div>
</div>
<div class="card-section" id="inspector">
<div class="card-section gen-card" style="margin-top:8px;">
<select class="gen-mode" id="generateModeSelect" aria-label="generation mode">
<option value="create">Create from scratch</option>
<option value="animate">Animate an image</option>
</select>
<div class="gen-mode-desc" id="generateModeDesc">Generate a video from text and images</div>
<textarea class="gen-prompt" id="generatePromptInput" placeholder="Describe your eight-second video. You can add ingredients such as brand images, characters and more."></textarea>
<div class="gen-meta">
<select class="ratio-select" id="generateRatioSelect" aria-label="aspect ratio">
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
</select>
</div>
<div class="duo gen-actions">
<button class="ghost-box" id="avatarUploadCard" type="button">
<span class="up-icon">👤</span>
<span id="avatarUploadText">Avatar</span>
</button>
<button class="ghost-box" id="ingredientsUploadCard" type="button">
<span class="up-icon">🖼</span>
<span id="ingredientsUploadText">Ingredients</span>
</button>
<input type="file" id="avatarFileInput" accept="image/*" hidden />
<input type="file" id="ingredientsFileInput" accept="image/*" hidden />
</div>
<div class="gen-actions">
<button class="gen-btn" id="generateApplyBtn">Generate</button>
</div>
</div>
<div class="progress-card" id="generateProgressCard" aria-label="generation progress">
<div class="progress-percent" id="generatePercentText">0%</div>
<div class="progress-hint" id="generateProgressHint"> </div>
<button class="progress-action" id="generateCancelBtn" type="button">Cancel</button>
</div>
<div class="generated-wrap">
<div class="generated-title">Generated Videos</div>
<div class="generated-list" id="generatedList">
<div class="status" style="margin-top:0;">暂无生成结果</div>
</div>
</div>
</div>
</aside>
<aside class="rail" id="rail">
<button class="rail-tool active"><span class="rail-icon">🧩</span><span class="rail-label">Veo</span></button>
<button class="rail-tool"><span class="rail-icon">👤</span><span class="rail-label">Avatar</span></button>
<button class="rail-tool"><span class="rail-icon">🎙</span><span class="rail-label">Voiceover</span></button>
<button class="rail-tool"><span class="rail-icon">🖼</span><span class="rail-label">Image</span></button>
<button class="rail-tool"><span class="rail-icon"></span><span class="rail-label">Record</span></button>
<button class="rail-tool"><span class="rail-icon">📁</span><span class="rail-label">Uploads</span></button>
<button class="rail-tool"><span class="rail-icon">🏷</span><span class="rail-label">Stock</span></button>
<button class="rail-tool"><span class="rail-icon">📝</span><span class="rail-label">Captions</span></button>
<button class="rail-tool"><span class="rail-icon">Tt</span><span class="rail-label">Text</span></button>
<button class="rail-tool"><span class="rail-icon">📐</span><span class="rail-label">Templates</span></button>
<button class="rail-tool"><span class="rail-icon"></span><span class="rail-label">Shapes</span></button>
</aside>
</div>
<script>
(function () {
var MIN_DURATION = 0.4;
var SNAP_STEP = 0.1;
var state = {
timeline: [],
duration: 10,
basePxPerSec: 92,
zoom: 1,
currentTime: 0,
isPlaying: false,
isScrubbing: false,
snap: true,
rafId: null,
lastTs: 0,
selectedClipId: null,
drag: null,
previewVideoError: {},
currentTaskId: null,
collapsedTracks: { audio: false, caption: false, video: false },
videoOnly: false,
history: [],
historyIndex: -1,
generatedVideos: [],
generating: false,
previewPinned: null,
generationPercent: 0,
generationTimer: null,
generatedDrawerOpen: false,
generationMode: "create",
avatarUpload: null,
ingredientsUpload: null,
};
var DEFAULT_TIMELINE = [
{
id: "v1",
track: "video",
start: 0,
duration: 3.2,
label: "城市远景开场",
preview: "/assets/demo.jpg",
note: "镜头 1 · 晨光推进",
mediaUrl: null,
},
{
id: "v2",
track: "video",
start: 3.2,
duration: 3,
label: "主角特写",
preview: "/assets/mock.png",
note: "镜头 2 · 人物停留",
mediaUrl: null,
},
{
id: "v3",
track: "video",
start: 6.2,
duration: 3.8,
label: "产品收尾",
preview: "/assets/demo.jpg",
note: "镜头 3 · Logo 落版",
mediaUrl: null,
},
{ id: "a1", track: "audio", start: 0, duration: 10, label: "BGM - Ambient Pulse" },
{ id: "a2", track: "audio", start: 1.6, duration: 6.8, label: "Voiceover - Scene narration" },
{ id: "c1", track: "caption", start: 0.8, duration: 2.3, label: "Caption: New day, new story" },
{ id: "c2", track: "caption", start: 3.4, duration: 2.6, label: "Caption: Keep creating" },
{ id: "c3", track: "caption", start: 6.7, duration: 2.4, label: "Caption: AI video workflow" },
];
var playBtn = document.getElementById("playBtn");
var snapBtn = document.getElementById("snapBtn");
var foldAudioBtn = document.getElementById("foldAudioBtn");
var videoOnlyBtn = document.getElementById("videoOnlyBtn");
var undoBtn = document.getElementById("undoBtn");
var redoBtn = document.getElementById("redoBtn");
var timeText = document.getElementById("timeText");
var zoomRange = document.getElementById("zoomRange");
var zoomText = document.getElementById("zoomText");
var rail = document.getElementById("rail");
var ruler = document.getElementById("ruler");
var tracks = document.getElementById("tracks");
var timelineInner = document.getElementById("timelineInner");
var timelineScroll = document.getElementById("timelineScroll");
var playhead = document.getElementById("playhead");
var previewVideo = document.getElementById("previewVideo");
var previewImage = document.getElementById("previewImage");
var previewTitle = document.getElementById("previewTitle");
var previewSub = document.getElementById("previewSub");
var addClipBtn = document.getElementById("addClipBtn");
var generatePromptInput = document.getElementById("generatePromptInput");
var generateModeSelect = document.getElementById("generateModeSelect");
var generateModeDesc = document.getElementById("generateModeDesc");
var generateRatioSelect = document.getElementById("generateRatioSelect");
var generateApplyBtn = document.getElementById("generateApplyBtn");
var generatedList = document.getElementById("generatedList");
var generatedWrap = document.querySelector(".generated-wrap");
var generateProgressCard = document.getElementById("generateProgressCard");
var generatePercentText = document.getElementById("generatePercentText");
var generateProgressHint = document.getElementById("generateProgressHint");
var generateCancelBtn = document.getElementById("generateCancelBtn");
var avatarUploadCard = document.getElementById("avatarUploadCard");
var ingredientsUploadCard = document.getElementById("ingredientsUploadCard");
var avatarUploadText = document.getElementById("avatarUploadText");
var ingredientsUploadText = document.getElementById("ingredientsUploadText");
var avatarFileInput = document.getElementById("avatarFileInput");
var ingredientsFileInput = document.getElementById("ingredientsFileInput");
var taskIdInput = document.getElementById("taskIdInput");
var loadTaskBtn = document.getElementById("loadTaskBtn");
var taskStatus = document.getElementById("taskStatus");
var clipLabelInput = document.getElementById("clipLabelInput");
var clipStartInput = document.getElementById("clipStartInput");
var clipDurationInput = document.getElementById("clipDurationInput");
var clipTrackInput = document.getElementById("clipTrackInput");
var jumpToClipBtn = document.getElementById("jumpToClipBtn");
var deleteClipBtn = document.getElementById("deleteClipBtn");
var GENERATED_VIDEO_SOURCES = [
"/assets/mock_videos/scene_01.mp4",
"/assets/mock_videos/scene_02.mp4",
"/assets/mock_videos/scene_03.mp4",
];
function cloneTimeline(timeline) {
var out = [];
for (var i = 0; i < timeline.length; i += 1) out.push(Object.assign({}, timeline[i]));
return out;
}
function snapshot() {
return {
timeline: cloneTimeline(state.timeline),
selectedClipId: state.selectedClipId,
currentTime: state.currentTime,
collapsedTracks: Object.assign({}, state.collapsedTracks),
videoOnly: state.videoOnly,
};
}
function isSameSnapshot(a, b) {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
function updateHistoryButtons() {
undoBtn.disabled = state.historyIndex <= 0;
redoBtn.disabled = state.historyIndex >= state.history.length - 1;
}
function pushHistory() {
var snapShot = snapshot();
var last = state.history[state.historyIndex];
if (isSameSnapshot(last, snapShot)) {
updateHistoryButtons();
return;
}
if (state.historyIndex < state.history.length - 1) {
state.history = state.history.slice(0, state.historyIndex + 1);
}
state.history.push(snapShot);
state.historyIndex = state.history.length - 1;
updateHistoryButtons();
}
function restoreSnapshot(s) {
if (!s) return;
state.timeline = cloneTimeline(s.timeline || []);
state.selectedClipId = s.selectedClipId || null;
state.currentTime = Number.isFinite(s.currentTime) ? s.currentTime : 0;
state.collapsedTracks = Object.assign({ audio: false, caption: false, video: false }, s.collapsedTracks || {});
state.videoOnly = !!s.videoOnly;
foldAudioBtn.classList.toggle("active", !!state.collapsedTracks.audio);
videoOnlyBtn.classList.toggle("active", state.videoOnly);
rerenderAll(state.currentTime);
updateHistoryButtons();
}
function undo() {
if (state.historyIndex <= 0) return;
state.historyIndex -= 1;
restoreSnapshot(state.history[state.historyIndex]);
}
function redo() {
if (state.historyIndex >= state.history.length - 1) return;
state.historyIndex += 1;
restoreSnapshot(state.history[state.historyIndex]);
}
function pxPerSec() {
return state.basePxPerSec * state.zoom;
}
function snap(value, noSnap) {
if (noSnap || !state.snap) return value;
return Math.round(value / SNAP_STEP) * SNAP_STEP;
}
function clipById(id) {
for (var i = 0; i < state.timeline.length; i += 1) {
if (state.timeline[i].id === id) return state.timeline[i];
}
return null;
}
function activeVideoClip(time) {
for (var i = 0; i < state.timeline.length; i += 1) {
var clip = state.timeline[i];
if (clip.track !== "video") continue;
if (time >= clip.start && time < clip.start + clip.duration) return clip;
}
return null;
}
function formatTime(value) {
var safe = Math.max(0, value);
var sec = Math.floor(safe);
var tenth = Math.floor((safe - sec) * 10);
var mm = String(Math.floor(sec / 60)).padStart(2, "0");
var ss = String(sec % 60).padStart(2, "0");
return mm + ":" + ss + "." + tenth;
}
function ensureDuration() {
var maxEnd = 10;
for (var i = 0; i < state.timeline.length; i += 1) {
var end = state.timeline[i].start + state.timeline[i].duration;
if (end > maxEnd) maxEnd = end;
}
state.duration = Math.max(2, Math.ceil(maxEnd * 10) / 10);
}
function visibleTrack(track) {
return !state.videoOnly || track === "video";
}
function rowHeight(track) {
if (!visibleTrack(track)) return 0;
return state.collapsedTracks[track] ? 36 : 72;
}
function resolveTrackOverlaps(trackName) {
var trackClips = [];
for (var i = 0; i < state.timeline.length; i += 1) {
if (state.timeline[i].track === trackName) trackClips.push(state.timeline[i]);
}
trackClips.sort(function (a, b) {
if (a.start !== b.start) return a.start - b.start;
return a.id.localeCompare(b.id);
});
var cursor = 0;
for (var j = 0; j < trackClips.length; j += 1) {
var clip = trackClips[j];
clip.start = Math.max(0, clip.start);
clip.duration = Math.max(MIN_DURATION, clip.duration);
if (clip.start < cursor) clip.start = cursor;
cursor = clip.start + clip.duration;
}
}
function markConflicts() {
for (var i = 0; i < state.timeline.length; i += 1) {
state.timeline[i].conflict = false;
}
var tracksToCheck = ["video", "audio", "caption"];
for (var t = 0; t < tracksToCheck.length; t += 1) {
var name = tracksToCheck[t];
var clips = [];
for (var j = 0; j < state.timeline.length; j += 1) {
if (state.timeline[j].track === name) clips.push(state.timeline[j]);
}
clips.sort(function (a, b) {
return a.start - b.start;
});
for (var k = 1; k < clips.length; k += 1) {
if (clips[k].start < clips[k - 1].start + clips[k - 1].duration - 1e-6) {
clips[k].conflict = true;
clips[k - 1].conflict = true;
}
}
}
}
function autoArrangeAllTracks() {
resolveTrackOverlaps("video");
resolveTrackOverlaps("audio");
resolveTrackOverlaps("caption");
markConflicts();
}
function applyTrackVisibility() {
var tracksOrder = ["video", "audio", "caption"];
var rowsTotal = 0;
for (var i = 0; i < tracksOrder.length; i += 1) {
var track = tracksOrder[i];
var row = tracks.querySelector('.track-row[data-track="' + track + '"]');
var label = document.querySelector('.track-label[data-track="' + track + '"]');
if (!row || !label) continue;
var show = visibleTrack(track);
var h = rowHeight(track);
row.style.display = show ? "" : "none";
label.style.display = show ? "" : "none";
if (show) {
row.style.height = h + "px";
label.style.height = h + "px";
row.classList.toggle("collapsed", h <= 36);
label.classList.toggle("collapsed", h <= 36);
rowsTotal += h;
}
}
tracks.style.height = rowsTotal + "px";
}
function updateTimeText() {
timeText.innerHTML = formatTime(state.currentTime) + " <small>/ " + formatTime(state.duration) + "</small>";
}
function applyClipElementGeometry(el, clip) {
el.style.left = clip.start * pxPerSec() + "px";
el.style.width = Math.max(44, clip.duration * pxPerSec()) + "px";
el.dataset.start = String(clip.start);
el.dataset.end = String(clip.start + clip.duration);
}
function renderGridLines() {
var prev = timelineInner.querySelectorAll(".grid-line");
for (var i = 0; i < prev.length; i += 1) prev[i].remove();
var total = Math.ceil(state.duration);
for (var sec = 0; sec <= total; sec += 1) {
var gl = document.createElement("div");
gl.className = "grid-line";
gl.style.left = sec * pxPerSec() + "px";
timelineInner.appendChild(gl);
}
}
function renderRuler() {
ruler.innerHTML = "";
timelineInner.style.width = state.duration * pxPerSec() + "px";
var total = Math.ceil(state.duration);
for (var sec = 0; sec <= total; sec += 1) {
var tick = document.createElement("div");
tick.className = "tick major";
tick.style.left = sec * pxPerSec() + "px";
var label = document.createElement("span");
label.className = "tick-label";
label.textContent = formatTime(sec);
tick.appendChild(label);
ruler.appendChild(tick);
if (sec < total) {
for (var sub = 1; sub < 5; sub += 1) {
var minor = document.createElement("div");
minor.className = "tick";
minor.style.left = (sec + sub / 5) * pxPerSec() + "px";
ruler.appendChild(minor);
}
}
}
renderGridLines();
}
function renderTracks() {
applyTrackVisibility();
var rows = tracks.querySelectorAll(".track-row");
for (var i = 0; i < rows.length; i += 1) rows[i].innerHTML = "";
for (var j = 0; j < state.timeline.length; j += 1) {
var clip = state.timeline[j];
var row = tracks.querySelector('.track-row[data-track="' + clip.track + '"]');
if (!row) continue;
var el = document.createElement("div");
el.className = "clip " + clip.track;
if (state.selectedClipId === clip.id) el.classList.add("selected");
if (clip.conflict) el.classList.add("conflict");
el.dataset.id = clip.id;
el.title = clip.label;
var leftHandle = document.createElement("div");
leftHandle.className = "clip-handle left";
var rightHandle = document.createElement("div");
rightHandle.className = "clip-handle right";
var text = document.createElement("span");
text.textContent = clip.label;
el.appendChild(leftHandle);
el.appendChild(text);
el.appendChild(rightHandle);
applyClipElementGeometry(el, clip);
el.addEventListener("pointerdown", onClipPointerDown);
el.addEventListener("click", function (event) {
var id = event.currentTarget.dataset.id;
selectClip(id);
});
row.appendChild(el);
}
updateActiveClips();
}
function updatePlayhead() {
var x = state.currentTime * pxPerSec();
playhead.style.transform = "translateX(" + x + "px)";
updateTimeText();
var viewLeft = timelineScroll.scrollLeft;
var viewRight = viewLeft + timelineScroll.clientWidth;
var padding = 24;
if (x < viewLeft + padding) {
timelineScroll.scrollLeft = Math.max(0, x - padding);
} else if (x > viewRight - padding) {
timelineScroll.scrollLeft = x - timelineScroll.clientWidth + padding;
}
}
function updateActiveClips() {
var all = timelineInner.querySelectorAll(".clip");
for (var i = 0; i < all.length; i += 1) {
var el = all[i];
var st = Number(el.dataset.start);
var ed = Number(el.dataset.end);
var active = state.currentTime >= st && state.currentTime < ed;
el.classList.toggle("active", active);
}
}
function setPreviewFallback(clip) {
previewVideo.classList.remove("active");
var imageSrc = (clip && clip.preview) || "/assets/demo.jpg";
if (previewImage.getAttribute("src") !== imageSrc) {
previewImage.setAttribute("src", imageSrc);
}
previewImage.classList.add("active");
}
function updatePreview() {
if (state.previewPinned && !state.isPlaying && !state.isScrubbing) {
var pin = state.previewPinned;
previewTitle.textContent = pin.label;
previewSub.textContent = "Generated preview · " + formatTime(pin.duration || 0);
if (previewVideo.getAttribute("src") !== pin.url) {
previewVideo.setAttribute("src", pin.url);
previewVideo.load();
}
previewImage.classList.remove("active");
previewVideo.classList.add("active");
return;
}
var active = activeVideoClip(state.currentTime);
if (!active) {
setPreviewFallback(null);
previewTitle.textContent = "镜头预览";
previewSub.textContent = "当前时间没有视频片段";
return;
}
previewTitle.textContent = active.label;
previewSub.textContent = (active.note || "") + " · " + formatTime(state.currentTime);
if (!active.mediaUrl || state.previewVideoError[active.mediaUrl]) {
setPreviewFallback(active);
return;
}
var absTime = state.currentTime - active.start;
var localTime = Math.max(0, Math.min(active.duration - 0.03, absTime));
if (previewVideo.getAttribute("src") !== active.mediaUrl) {
previewVideo.setAttribute("src", active.mediaUrl);
previewVideo.load();
}
if (Math.abs(previewVideo.currentTime - localTime) > 0.08 && Number.isFinite(previewVideo.duration)) {
try {
previewVideo.currentTime = localTime;
} catch (_e) {
// Ignore browser seek race.
}
}
previewImage.classList.remove("active");
previewVideo.classList.add("active");
}
function setTime(nextTime) {
state.currentTime = Math.max(0, Math.min(state.duration, nextTime));
updatePlayhead();
updateActiveClips();
updatePreview();
}
function rerenderAll(keepTime) {
autoArrangeAllTracks();
ensureDuration();
renderRuler();
renderTracks();
renderInspector();
setTime(keepTime == null ? state.currentTime : keepTime);
}
function selectClip(id) {
state.previewPinned = null;
state.selectedClipId = id;
renderTracks();
renderInspector();
}
function clearSelection() {
state.previewPinned = null;
state.selectedClipId = null;
renderTracks();
renderInspector();
}
function removeSelectedClip() {
if (!state.selectedClipId) return;
var next = [];
for (var i = 0; i < state.timeline.length; i += 1) {
if (state.timeline[i].id !== state.selectedClipId) next.push(state.timeline[i]);
}
state.timeline = next;
state.selectedClipId = null;
rerenderAll();
pushHistory();
}
function findCaptionByStart(startTime) {
var best = null;
var diff = Number.POSITIVE_INFINITY;
for (var i = 0; i < state.timeline.length; i += 1) {
var clip = state.timeline[i];
if (clip.track !== "caption") continue;
var d = Math.abs(clip.start - startTime);
if (d < diff) {
diff = d;
best = clip;
}
}
return best;
}
function normalizePromptText(text) {
return String(text || "")
.replace(/\s+/g, " ")
.trim();
}
function renderGeneratedList() {
if (!generatedList) return;
if (generatedWrap) generatedWrap.classList.toggle("collapsed", !state.generatedDrawerOpen);
if (!state.generatedVideos.length) {
generatedList.innerHTML = '<div class="status" style="margin-top:0;">暂无生成结果</div>';
return;
}
var html = "";
for (var i = 0; i < state.generatedVideos.length; i += 1) {
var item = state.generatedVideos[i];
html +=
'<div class="generated-item" data-id="' +
item.id +
'">' +
'<video class="generated-video" src="' +
item.url +
'" muted playsinline preload="metadata"></video>' +
'<div class="generated-meta">' +
item.label +
" · " +
(item.ratio || "16:9") +
" · " +
formatTime(item.duration) +
"</div>" +
'<div class="generated-actions">' +
'<button class="btn" data-action="preview">Preview</button>' +
'<button class="btn primary" data-action="insert">' +
(item.inserted ? "Inserted" : "Insert") +
"</button>" +
"</div>" +
"</div>";
}
generatedList.innerHTML = html;
}
function renderProgressCard() {
if (!generatePercentText || !generateProgressHint || !generateCancelBtn) return;
if (generateProgressCard) generateProgressCard.classList.toggle("active", state.generating);
var pct = Math.max(0, Math.min(100, Math.round(state.generationPercent)));
generatePercentText.textContent = pct + "%";
if (state.generating) {
generateProgressHint.textContent = "Generating...";
generateCancelBtn.textContent = "Cancel";
} else {
generateProgressHint.textContent = " ";
generateCancelBtn.textContent = "Cancel";
}
}
function fileLabel(file) {
if (!file) return "";
var name = String(file.name || "image");
return name.length > 16 ? name.slice(0, 16) + "..." : name;
}
function applyUploadCardState(card, textNode, file, fallback) {
if (!card || !textNode) return;
if (file) {
card.classList.add("has-file");
textNode.textContent = fileLabel(file);
} else {
card.classList.remove("has-file");
textNode.textContent = fallback;
}
}
function renderModeUI() {
var mode = state.generationMode;
if (generateModeDesc) {
generateModeDesc.textContent =
mode === "animate" ? "Turn an image into a video" : "Generate a video from text and images";
}
if (mode === "animate") {
if (avatarUploadCard) avatarUploadCard.classList.add("hidden");
state.avatarUpload = null;
} else if (avatarUploadCard) {
avatarUploadCard.classList.remove("hidden");
}
applyUploadCardState(avatarUploadCard, avatarUploadText, state.avatarUpload, "Avatar");
applyUploadCardState(ingredientsUploadCard, ingredientsUploadText, state.ingredientsUpload, "Ingredients");
}
function stopGenerationTimer() {
if (state.generationTimer) {
window.clearInterval(state.generationTimer);
state.generationTimer = null;
}
}
function cancelGenerationOrClear() {
if (state.generating) {
stopGenerationTimer();
state.generating = false;
state.generationPercent = 0;
updateGenerateButtonState();
renderProgressCard();
return;
}
}
function previewGeneratedVideo(id) {
for (var i = 0; i < state.generatedVideos.length; i += 1) {
if (state.generatedVideos[i].id === id) {
state.previewPinned = state.generatedVideos[i];
updatePreview();
break;
}
}
}
function insertGeneratedVideo(id) {
var target = null;
for (var i = 0; i < state.generatedVideos.length; i += 1) {
if (state.generatedVideos[i].id === id) {
target = state.generatedVideos[i];
break;
}
}
if (!target) return;
var clipId = "gen_" + String(Date.now());
state.timeline.push({
id: clipId,
track: "video",
start: snap(state.currentTime),
duration: target.duration,
label: target.label + " (" + target.ratio + ")",
note: "Generated clip · " + target.mode + " · " + target.ratio,
preview: "/assets/demo.jpg",
mediaUrl: target.url,
});
state.selectedClipId = clipId;
target.inserted = true;
state.previewPinned = null;
rerenderAll();
renderGeneratedList();
pushHistory();
renderProgressCard();
}
function pickGeneratedSource() {
var index = state.generatedVideos.length % GENERATED_VIDEO_SOURCES.length;
return GENERATED_VIDEO_SOURCES[index];
}
function generateToSelectedClip() {
var prompt = normalizePromptText(generatePromptInput.value);
if (!prompt) {
return;
}
if (state.generating) return;
if (state.generationMode === "animate" && !state.ingredientsUpload) return;
state.generating = true;
state.generationPercent = 0;
state.generatedDrawerOpen = false;
renderGeneratedList();
renderProgressCard();
updateGenerateButtonState();
var duration = Math.max(MIN_DURATION, Math.min(8, Math.round(Math.max(2.2, prompt.length / 9) * 10) / 10));
var compactPrompt = prompt.length > 30 ? prompt.slice(0, 30) + "..." : prompt;
var source = pickGeneratedSource();
var ratio = (generateRatioSelect && generateRatioSelect.value) || "16:9";
var id = "gv_" + Date.now();
var target = 100;
var step = 6 + Math.floor(Math.random() * 4);
stopGenerationTimer();
state.generationTimer = window.setInterval(function () {
state.generationPercent += step;
if (state.generationPercent >= target) {
state.generationPercent = 100;
stopGenerationTimer();
state.generatedVideos.unshift({
id: id,
url: source,
label: compactPrompt,
ratio: ratio,
mode: state.generationMode === "animate" ? "Animate an image" : "Create from scratch",
duration: duration,
inserted: false,
});
state.generating = false;
state.generatedDrawerOpen = true;
state.previewPinned = state.generatedVideos[0];
updatePreview();
renderGeneratedList();
updateGenerateButtonState();
renderProgressCard();
return;
}
renderProgressCard();
}, 120);
}
function updateGenerateButtonState() {
var prompt = normalizePromptText(generatePromptInput.value);
var enabled = !state.generating;
if (state.generationMode === "animate") {
enabled = enabled && !!state.ingredientsUpload;
} else {
enabled = enabled && !!prompt;
}
generateApplyBtn.classList.toggle("ready", enabled);
generateApplyBtn.disabled = !enabled;
if (!enabled && state.generating) {
generateApplyBtn.textContent = "Generating...";
} else {
generateApplyBtn.textContent = "Generate";
}
}
function renderInspector() {
if (!clipLabelInput) return;
var clip = clipById(state.selectedClipId);
var enabled = !!clip;
clipLabelInput.disabled = !enabled;
clipStartInput.disabled = !enabled;
clipDurationInput.disabled = !enabled;
clipTrackInput.disabled = !enabled;
jumpToClipBtn.disabled = !enabled;
deleteClipBtn.disabled = !enabled;
if (!enabled) {
clipLabelInput.value = "";
clipStartInput.value = "";
clipDurationInput.value = "";
clipTrackInput.value = "video";
return;
}
clipLabelInput.value = clip.label || "";
clipStartInput.value = String(Math.round(clip.start * 10) / 10);
clipDurationInput.value = String(Math.round(clip.duration * 10) / 10);
clipTrackInput.value = clip.track;
}
function updateClipFromInspector() {
if (!clipLabelInput) return;
var clip = clipById(state.selectedClipId);
if (!clip) return;
clip.label = clipLabelInput.value || clip.label;
clip.track = clipTrackInput.value;
var nextStart = Number(clipStartInput.value);
if (Number.isFinite(nextStart)) clip.start = Math.max(0, snap(nextStart, true));
var nextDuration = Number(clipDurationInput.value);
if (Number.isFinite(nextDuration)) clip.duration = Math.max(MIN_DURATION, snap(nextDuration, true));
rerenderAll();
pushHistory();
}
function clientXToTime(clientX) {
var box = timelineInner.getBoundingClientRect();
var x = clientX - box.left;
return x / pxPerSec();
}
function startScrub(clientX) {
state.previewPinned = null;
state.isScrubbing = true;
setTime(clientXToTime(clientX));
}
function onClipPointerDown(event) {
event.preventDefault();
event.stopPropagation();
var el = event.currentTarget;
var id = el.dataset.id;
var clip = clipById(id);
if (!clip) return;
selectClip(id);
var mode = "move";
if (event.target.classList.contains("clip-handle")) {
mode = event.target.classList.contains("left") ? "resize-left" : "resize-right";
}
state.drag = {
type: mode,
clipId: id,
startX: event.clientX,
originStart: clip.start,
originDuration: clip.duration,
pointerId: event.pointerId,
};
el.setPointerCapture(event.pointerId);
}
function onPointerMove(event) {
if (state.isScrubbing) {
setTime(clientXToTime(event.clientX));
return;
}
if (!state.drag) return;
var clip = clipById(state.drag.clipId);
if (!clip) return;
var deltaSec = (event.clientX - state.drag.startX) / pxPerSec();
var noSnap = event.shiftKey;
if (state.drag.type === "move") {
clip.start = Math.max(0, snap(state.drag.originStart + deltaSec, noSnap));
} else if (state.drag.type === "resize-left") {
var proposedStart = Math.max(0, snap(state.drag.originStart + deltaSec, noSnap));
var maxStart = state.drag.originStart + state.drag.originDuration - MIN_DURATION;
proposedStart = Math.min(maxStart, proposedStart);
clip.duration = Math.max(MIN_DURATION, state.drag.originDuration + (state.drag.originStart - proposedStart));
clip.start = proposedStart;
} else if (state.drag.type === "resize-right") {
clip.duration = Math.max(MIN_DURATION, snap(state.drag.originDuration + deltaSec, noSnap));
}
autoArrangeAllTracks();
renderTracks();
updatePlayhead();
updatePreview();
renderInspector();
}
function onPointerUp() {
if (state.isScrubbing) state.isScrubbing = false;
if (state.drag) {
state.drag = null;
rerenderAll();
pushHistory();
}
}
function stopPlay() {
state.isPlaying = false;
playBtn.textContent = "▶";
if (state.rafId) cancelAnimationFrame(state.rafId);
state.rafId = null;
}
function step(ts) {
if (!state.isPlaying) return;
if (!state.lastTs) state.lastTs = ts;
var dt = (ts - state.lastTs) / 1000;
state.lastTs = ts;
if (state.currentTime >= state.duration) {
stopPlay();
return;
}
setTime(state.currentTime + dt);
state.rafId = requestAnimationFrame(step);
}
function togglePlay() {
state.previewPinned = null;
state.isPlaying = !state.isPlaying;
playBtn.textContent = state.isPlaying ? "❚❚" : "▶";
if (state.isPlaying) {
if (state.currentTime >= state.duration) setTime(0);
state.lastTs = 0;
state.rafId = requestAnimationFrame(step);
} else {
stopPlay();
}
}
function addClipAtCurrentTime() {
var id = "clip_" + Date.now();
var clip = {
id: id,
track: "video",
start: snap(state.currentTime),
duration: 2,
label: "新镜头",
note: "新建片段",
preview: "/assets/mock.png",
mediaUrl: null,
};
state.timeline.push(clip);
state.selectedClipId = id;
rerenderAll();
pushHistory();
}
function durationByNarration(text, index) {
if (!text) return 3 + (index % 2) * 0.4;
var len = String(text).replace(/\s+/g, "").length;
return Math.min(6.2, Math.max(2.2, len / 6.5));
}
function parseSceneNum(shotId, fallback) {
var match = /scene_(\d+)_/i.exec(String(shotId || ""));
if (!match) return fallback;
return Number(match[1]) || fallback;
}
function buildTimelineFromTask(taskId, task, scenesPayload) {
var shots = Array.isArray(task && task.shots) ? task.shots : [];
var scenes = scenesPayload && Array.isArray(scenesPayload.scenes) ? scenesPayload.scenes : [];
var out = [];
var cursor = 0;
for (var i = 0; i < shots.length; i += 1) {
var shot = shots[i] || {};
var shotId = String(shot.shot_id || "scene_" + String(i + 1).padStart(2, "0") + "_01");
var sceneNo = parseSceneNum(shotId, i + 1);
var scene = scenes[sceneNo - 1] || scenes[i] || {};
var narration = scene.narration || "";
var motion = scene.video_motion || "";
var duration = durationByNarration(narration, i);
var videoUrl = "/api/static/" + taskId + "/clips/shot_" + shotId + ".mp4";
var audioUrl = "/api/static/" + taskId + "/audio/shot_" + shotId + ".mp3";
out.push({
id: "v_" + shotId,
track: "video",
start: cursor,
duration: duration,
label: "Scene " + sceneNo,
note: motion || "镜头预览",
preview: "/assets/demo.jpg",
mediaUrl: videoUrl,
});
out.push({
id: "a_" + shotId,
track: "audio",
start: cursor,
duration: duration,
label: "VO " + sceneNo,
mediaUrl: audioUrl,
});
out.push({
id: "c_" + shotId,
track: "caption",
start: cursor,
duration: duration,
label: narration ? "Caption: " + narration.slice(0, 24) : "Caption " + sceneNo,
});
cursor += duration;
}
return out;
}
async function loadTask(taskId) {
var safeId = String(taskId || "").trim();
if (!safeId) return;
if (taskStatus) taskStatus.textContent = "正在加载任务 " + safeId + " ...";
if (loadTaskBtn) loadTaskBtn.disabled = true;
try {
var taskRes = await fetch("/api/tasks/" + encodeURIComponent(safeId));
if (!taskRes.ok) throw new Error("task not found");
var taskJson = await taskRes.json();
var scenes = null;
try {
var scenesRes = await fetch("/api/static/" + encodeURIComponent(safeId) + "/scenes.json", { cache: "no-store" });
if (scenesRes.ok) scenes = await scenesRes.json();
} catch (_ignore) {
scenes = null;
}
var timeline = buildTimelineFromTask(safeId, taskJson, scenes);
if (!timeline.length) throw new Error("no shots");
state.currentTaskId = safeId;
state.timeline = timeline;
state.previewVideoError = {};
state.selectedClipId = timeline[0].id;
state.videoOnly = false;
state.collapsedTracks.audio = false;
state.collapsedTracks.caption = false;
videoOnlyBtn.classList.remove("active");
foldAudioBtn.classList.remove("active");
ensureDuration();
rerenderAll(0);
state.history = [];
state.historyIndex = -1;
pushHistory();
var shotCount = Array.isArray(taskJson.shots) ? taskJson.shots.length : 0;
if (taskStatus) taskStatus.textContent = "已加载任务 " + safeId + ",共 " + shotCount + " 个镜头。";
} catch (err) {
if (taskStatus) taskStatus.textContent = "加载失败,继续使用示例时间线。(" + String(err.message || err) + ")";
} finally {
if (loadTaskBtn) loadTaskBtn.disabled = false;
}
}
previewVideo.addEventListener("error", function () {
var src = previewVideo.getAttribute("src");
if (src) state.previewVideoError[src] = true;
setPreviewFallback(activeVideoClip(state.currentTime));
});
playBtn.addEventListener("click", togglePlay);
snapBtn.addEventListener("click", function () {
state.snap = !state.snap;
snapBtn.classList.toggle("active", state.snap);
});
foldAudioBtn.addEventListener("click", function () {
state.collapsedTracks.audio = !state.collapsedTracks.audio;
foldAudioBtn.classList.toggle("active", state.collapsedTracks.audio);
renderTracks();
pushHistory();
});
videoOnlyBtn.addEventListener("click", function () {
state.videoOnly = !state.videoOnly;
videoOnlyBtn.classList.toggle("active", state.videoOnly);
if (state.videoOnly) {
var selected = clipById(state.selectedClipId);
if (selected && selected.track !== "video") state.selectedClipId = null;
}
renderTracks();
renderInspector();
pushHistory();
});
undoBtn.addEventListener("click", undo);
redoBtn.addEventListener("click", redo);
zoomRange.addEventListener("input", function () {
state.zoom = Number(zoomRange.value) / 100;
zoomText.textContent = zoomRange.value + "%";
renderRuler();
renderTracks();
updatePlayhead();
});
addClipBtn.addEventListener("click", addClipAtCurrentTime);
if (loadTaskBtn && taskIdInput) {
loadTaskBtn.addEventListener("click", function () {
loadTask(taskIdInput.value);
});
}
generateApplyBtn.addEventListener("click", generateToSelectedClip);
generatePromptInput.addEventListener("input", updateGenerateButtonState);
if (generateModeSelect) {
generateModeSelect.addEventListener("change", function () {
state.generationMode = generateModeSelect.value || "create";
renderModeUI();
updateGenerateButtonState();
});
}
if (avatarUploadCard && avatarFileInput) {
avatarUploadCard.addEventListener("click", function () {
if (state.generationMode === "animate") return;
avatarFileInput.click();
});
avatarFileInput.addEventListener("change", function () {
state.avatarUpload = avatarFileInput.files && avatarFileInput.files[0] ? avatarFileInput.files[0] : null;
renderModeUI();
updateGenerateButtonState();
});
}
if (ingredientsUploadCard && ingredientsFileInput) {
ingredientsUploadCard.addEventListener("click", function () {
ingredientsFileInput.click();
});
ingredientsFileInput.addEventListener("change", function () {
state.ingredientsUpload =
ingredientsFileInput.files && ingredientsFileInput.files[0] ? ingredientsFileInput.files[0] : null;
renderModeUI();
updateGenerateButtonState();
});
}
if (clipLabelInput) {
clipLabelInput.addEventListener("change", updateClipFromInspector);
clipStartInput.addEventListener("change", updateClipFromInspector);
clipDurationInput.addEventListener("change", updateClipFromInspector);
clipTrackInput.addEventListener("change", updateClipFromInspector);
jumpToClipBtn.addEventListener("click", function () {
var clip = clipById(state.selectedClipId);
if (!clip) return;
setTime(clip.start + 0.01);
});
deleteClipBtn.addEventListener("click", removeSelectedClip);
}
if (generatedList) {
generatedList.addEventListener("click", function (event) {
var btn = event.target.closest("button[data-action]");
if (!btn) return;
var item = event.target.closest(".generated-item");
if (!item) return;
var id = item.dataset.id;
if (btn.dataset.action === "preview") previewGeneratedVideo(id);
if (btn.dataset.action === "insert") insertGeneratedVideo(id);
});
}
if (generateProgressCard) {
generateProgressCard.addEventListener("click", function () {
if (!state.generatedVideos.length) return;
state.generatedDrawerOpen = !state.generatedDrawerOpen;
renderGeneratedList();
renderProgressCard();
});
}
if (generateCancelBtn) {
generateCancelBtn.addEventListener("click", function (event) {
event.stopPropagation();
cancelGenerationOrClear();
});
}
rail.addEventListener("click", function (event) {
var btn = event.target.closest(".rail-tool");
if (!btn) return;
var all = rail.querySelectorAll(".rail-tool");
for (var i = 0; i < all.length; i += 1) all[i].classList.remove("active");
btn.classList.add("active");
});
ruler.addEventListener("pointerdown", function (event) {
clearSelection();
startScrub(event.clientX);
});
tracks.addEventListener("pointerdown", function (event) {
if (event.target.closest(".clip")) return;
clearSelection();
startScrub(event.clientX);
});
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("keydown", function (event) {
var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : "";
var editing = tag === "input" || tag === "textarea" || tag === "select";
if (event.code === "Space" && !editing) {
event.preventDefault();
togglePlay();
}
if ((event.key === "Delete" || event.key === "Backspace") && !editing) {
if (state.selectedClipId) {
event.preventDefault();
removeSelectedClip();
}
}
if (event.key === "Escape") {
clearSelection();
}
var isUndo = (event.metaKey || event.ctrlKey) && !event.shiftKey && (event.key === "z" || event.key === "Z");
var isRedo =
((event.metaKey || event.ctrlKey) && event.shiftKey && (event.key === "z" || event.key === "Z")) ||
((event.metaKey || event.ctrlKey) && (event.key === "y" || event.key === "Y"));
if (isUndo) {
event.preventDefault();
undo();
} else if (isRedo) {
event.preventDefault();
redo();
}
});
function init() {
state.timeline = DEFAULT_TIMELINE.slice();
state.generationPercent = 100;
ensureDuration();
state.selectedClipId = state.timeline[0].id;
rerenderAll(0.01);
pushHistory();
updateGenerateButtonState();
renderModeUI();
renderGeneratedList();
renderProgressCard();
var params = new URLSearchParams(window.location.search);
var taskIdFromQuery = params.get("task_id");
if (taskIdFromQuery && taskIdInput) {
taskIdInput.value = taskIdFromQuery;
loadTask(taskIdFromQuery);
}
}
init();
})();
</script>
</body>
</html>