Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func run() error {
queue.Register(jobs.TypeConvert, ffmpeg)
queue.Register(jobs.TypeCompress, ffmpeg)
queue.Register(jobs.TypeImage, image)
queue.Register(jobs.TypeEdit, ffmpeg)
if n, err := queue.Resume(ctx); err != nil {
log.Warn("resume queued jobs", "err", err)
} else if n > 0 {
Expand Down
14 changes: 12 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,8 @@ or a file that is no longer on disk, 500 `upload_failed` for a write error.
}
```

`type` is `download`, `convert`, `compress` or `image`. A `download` job needs
`url`; the other three need `upload_id` instead. `params` is validated against
`type` is `download`, `convert`, `compress`, `image` or `edit`. A `download` job
needs `url`; the others need `upload_id` instead. `params` is validated against
the type-specific struct with unknown fields rejected - a typo'd key is a 400,
not a silently ignored option.

Expand Down Expand Up @@ -276,6 +276,16 @@ which side of the threshold becomes paths), `quality`
(1–100, default 82), `width`, `height` (0 keeps that dimension, max 20000),
`strip_metadata` (default true), `retention_days`.

`edit`: `start` and `end` in seconds (`end: 0` runs to the end of the file, and
the cut must keep at least a tenth of a second), `crop_x`, `crop_y`, `crop_w`,
`crop_h` in source pixels (`crop_w: 0` keeps the whole frame; width and height
are set together, rounded down to an even number and at least 16),
`retention_days`. The cut lands exactly where it was asked for, which a stream
copy cannot do - it can only start on a keyframe - so video is re-encoded to
H.264 at CRF 18 while the audio is copied, landing in MP4 or, where H.264 does
not belong, MKV. A file with no video track (mp3, m4a, flac, ...) is copied
whole and keeps its own container.

Validation is not only per field. Container/codec combinations ffmpeg would
refuse to mux are rejected up front - WebM only takes VP9 or AV1 with Opus, MP4
and MOV reject VP9 and FLAC. `video_codec: "copy"` with a non-custom preset is
Expand Down
227 changes: 221 additions & 6 deletions frontend/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1143,9 +1143,9 @@ th.num {
display: block;
font-size: var(--fs-micro);
}
/* Echoes the queue flyout's own bar, so a running job reads the same way
whether it is watched from the card or the docked queue. */

.telemetry .bar {
display: none;
margin-top: 5px;
}

Expand Down Expand Up @@ -1301,8 +1301,8 @@ th.num {
position: relative;
z-index: 1;
grid-area: 1 / 1;
width: 13px;
height: 13px;
width: 16px;
height: 16px;
display: block;
}
.queue-ring > * {
Expand Down Expand Up @@ -1798,8 +1798,9 @@ th.num {
align-items: baseline;
gap: var(--sp-2);
}
/* The card-bottom rail would just repeat this bar; one progress
indicator per running job is plenty. */
.telemetry .bar {
display: block;
}
.job[data-status="running"] .job-rail {
display: none;
}
Expand Down Expand Up @@ -2048,3 +2049,217 @@ select.flash-b ~ .xsel-btn {
.library-item .file-name {
display: block;
}

/* editor - trim and crop */

.editor-card {
width: min(94vw, 760px);
}

.editor-inner {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}

.editor-body {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-4);
min-height: 0;
overflow-y: auto;
}

.ed-frame {
position: relative;
width: 100%;
margin-inline: auto;
overflow: hidden;
border-radius: var(--radius);
background: #000;
}
.ed-frame video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Nothing to show for an audio file - the track below is the whole editor. */
.ed-frame.is-audio {
height: 0;
background: transparent;
}

.ed-busy {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
pointer-events: none;
}
.ed-busy svg {
width: 28px;
height: 28px;
}

/* The spread shadow dims everything outside the box; the frame clips it. */
.ed-crop {
position: absolute;
touch-action: none;
cursor: move;
border: 1px solid #fff;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}
/* Corner brackets rather than dots: they show which edges a grip moves, and
the ::before widens the target well past the ink for a fingertip. */
.ed-grip {
position: absolute;
width: 20px;
height: 20px;
border: 3px solid #fff;
touch-action: none;
}
.ed-grip::before {
content: "";
position: absolute;
inset: -12px;
}
.ed-grip.nw { left: -2px; top: -2px; border-right: 0; border-bottom: 0; cursor: nwse-resize; }
.ed-grip.ne { right: -2px; top: -2px; border-left: 0; border-bottom: 0; cursor: nesw-resize; }
.ed-grip.sw { left: -2px; bottom: -2px; border-right: 0; border-top: 0; cursor: nesw-resize; }
.ed-grip.se { right: -2px; bottom: -2px; border-left: 0; border-top: 0; cursor: nwse-resize; }

.ed-track {
position: relative;
height: 38px;
margin-inline: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-3);
touch-action: none;
cursor: pointer;
}
.ed-keep {
position: absolute;
top: 0;
bottom: 0;
background: var(--focus-soft);
}
.ed-head {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: var(--run);
}
.ed-handle {
position: absolute;
top: -4px;
bottom: -4px;
width: 12px;
margin-left: -6px;
border-radius: 3px;
background: var(--ink);
cursor: ew-resize;
touch-action: none;
}
/* An invisible skirt, so the grab area is a thumb wide without the bar being. */
.ed-handle::before {
content: "";
position: absolute;
inset: -4px -9px;
}
/* A hairline down the middle reads as a grip without another element. */
.ed-handle::after {
content: "";
position: absolute;
left: 50%;
top: 12px;
bottom: 12px;
width: 2px;
margin-left: -1px;
border-radius: 1px;
background: var(--surface);
}

.ed-times {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--muted);
}

.ed-stat {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 3px var(--sp-2);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
}
.ed-stat .mono {
color: var(--text);
}
.ed-stat-k {
font-size: var(--fs-micro);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
/* The length is what the user is actually choosing, so it leads. */
.ed-stat.is-kept {
margin-left: auto;
border-color: var(--line-strong);
}
.ed-stat.is-kept .mono {
font-weight: 600;
}

.ed-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.ed-play-ico {
display: inline-flex;
}
.ed-play-ico > :nth-child(2) {
display: none;
}
.ed-play-ico.is-playing > :nth-child(1) {
display: none;
}
.ed-play-ico.is-playing > :nth-child(2) {
display: inline-block;
}
.ed-reset {
margin-left: auto;
}
.btn[aria-pressed="true"] {
border-color: var(--ink);
background: var(--ink);
color: var(--ink-text);
}
.btn[aria-pressed="true"]:hover:not(:disabled) {
border-color: var(--ink-hover);
background: var(--ink-hover);
color: var(--ink-text);
}

@media (max-width: 560px) {
.ed-stat {
padding: 3px 6px;
}
.ed-times {
gap: 6px;
}
}
Loading
Loading