2277 lines
71 KiB
HTML
2277 lines
71 KiB
HTML
<!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>
|