diff --git a/app/api/jobs.py b/app/api/jobs.py index 33f508f8..f2be8f41 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -33,7 +33,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove -from app.core.settings import get_max_duration_sec +from app.core.settings import get_auto_sections, get_max_duration_sec from app.core.stems_location import is_relocating from app.pipeline import jobqueue from app.pipeline.collect import merge_stem_peaks, presence_for_split @@ -190,7 +190,15 @@ async def _create_youtube_job(request: Request) -> dict[str, str]: if not selected: selected = list(STEM_NAMES) - job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url) + job = Job( + id=uuid.uuid4().hex[:12], + selected_stems=selected, + source_url=url, + # Captured now, not when the sections stage is reached: that is the + # last thing the pipeline does, and the toggle clears itself as soon + # as the user opens another song. + auto_sections=get_auto_sections(), + ) if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS): raise HTTPException(status_code=503, detail=_URL_QUEUE_FULL_DETAIL) jobqueue.enqueue(job.id) @@ -282,6 +290,7 @@ async def _create_local_job(request: Request) -> dict[str, str]: title=title, duration_sec=duration, source_url=local_source_url, + auto_sections=get_auto_sections(), ) if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS): shutil.rmtree(job_dir, ignore_errors=True) diff --git a/app/api/playlist.py b/app/api/playlist.py index bd0ac63b..6a2fd7cd 100644 --- a/app/api/playlist.py +++ b/app/api/playlist.py @@ -23,7 +23,7 @@ from app.core.registry import pending_count as registry_pending_count from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity -from app.core.settings import get_max_duration_sec, get_playlist_max_items +from app.core.settings import get_auto_sections, get_max_duration_sec, get_playlist_max_items from app.core.stems_location import is_relocating from app.pipeline import jobqueue from app.pipeline.download import InvalidPlaylistURL, expand_playlist @@ -139,6 +139,9 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]: if _capacity_left() == 0: raise HTTPException(status_code=503, detail="Queue is full - wait or cancel a job") + # Read once for the batch, so every job in one playlist import agrees, and + # captured now rather than when each job reaches its sections stage. + auto_sections = get_auto_sections() created: list[dict[str, Any]] = [] for item in items: job = Job( @@ -149,6 +152,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]: # immediately, instead of a URL until each download starts. title=item["title"] or None, thumbnail=item.get("thumbnail"), + auto_sections=auto_sections, ) if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS): break # queue filled up mid-loop; report what did land diff --git a/app/core/models.py b/app/core/models.py index 593634cb..e6a4d73e 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -49,6 +49,12 @@ class Job: tempo_stability: int | None = None # 0-100, beat interval consistency stem_presence: dict[str, int] | None = None # per-stem RMS 0-100 sections: list[dict] | None = None # [{id, name, kind?, start, end, color}] + # Whether this job should run the automatic song-structure pass, captured + # from the setting when the job is created rather than read when the stage + # is reached. The stage runs at the very end of the pipeline, minutes after + # submit, and the toggle is a per-import choice that clears itself: reading + # it late let a job lose a pass the user had asked and waited for. + auto_sections: bool = False sections_source: Literal["automatic", "manual"] | None = None tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8 stems: list[dict[str, str]] = field(default_factory=list) diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 002f1e3e..07603ab4 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -14,7 +14,6 @@ from app.core.models import Job, JobCancelled, _set from app.core.redact import redact from app.core.registry import persist as persist_registry -from app.core.settings import get_auto_sections from app.pipeline.analyze import analyze from app.pipeline.beatgrid import compute_beat_grid from app.pipeline.collect import ( @@ -219,11 +218,15 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: # Automatic sections are suggestions and never make an otherwise usable # separation fail. Cancellation remains authoritative so a user can still - # stop a long CPU inference pass immediately. The setting is read here, per - # job, rather than captured at import, so turning the toggle off applies to - # the next job without a restart. + # stop a long CPU inference pass immediately. + # + # The flag comes from the job, captured when it was created, not from the + # setting as it stands now. This stage is the last thing the pipeline does, + # so "now" can be many minutes after the user asked -- and the toggle clears + # itself on the next song they open. Reading it here let an import silently + # lose a pass its owner had already waited for. _check_cancel(job) - if get_auto_sections() and job.sections is None and job.duration_sec and job.duration_sec > 0: + if job.auto_sections and job.sections is None and job.duration_sec and job.duration_sec > 0: _set(job, stage="Analyzing song structure...") try: sections = detect_sections(job, stems_dir, job.duration_sec) diff --git a/static/css/daw.css b/static/css/daw.css index 14dbd481..b25c9210 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -42,7 +42,12 @@ input, textarea { font-family: inherit; } TOPBAR ═══════════════════════════════════ */ .daw-topbar { - height: 77px; + /* Was a flat 77px. The panel-toggle row under the composer needs a second + line, and auto height means a translation that wraps grows the bar rather + than being clipped. */ + min-height: 77px; + padding-top: 10px; + padding-bottom: 10px; flex-shrink: 0; background: var(--bg-2); border-bottom: 1px solid var(--border); @@ -1687,6 +1692,80 @@ input, textarea { font-family: inherit; } padding: 0 14px; } +/* ── Panel toggles (#480) ── */ +/* A second row under the composer, aligned to its right-hand end so it reads as + belonging to the two controls above it. Collapse to nothing, not to a stub: + the whole point is the height back, and a stub tall enough to hold a control + is most of what the smaller panels are worth. That is only possible because + the controls live up here, where the way back is always in the same place. + + This row costs the topbar about 19px, against up to 202px it can return. */ +.daw-composer-stack { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.daw-composer-stack > .daw-composer { flex: none; } +/* Exactly as wide as the two controls above it, and sharing their right edge: + the same --composer-action-w they are each sized from, so a longer + translation widens all three together instead of leaving this one ragged. + margin-left:auto does the alignment, since the stack is the composer's width. */ +.daw-panel-toggles { + width: calc(var(--composer-action-w) * 2); + margin-left: auto; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 2px; + padding: 2px 7px; + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + min-width: 0; +} +.daw-panel-toggles-label, +.daw-panel-toggles-sep { + font-size: 8px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--muted); + line-height: 1; + white-space: nowrap; +} +.daw-panel-toggle { + padding: 2px 3px; + border: 0; + border-radius: 4px; + background: none; + color: var(--fg-2); + font-family: inherit; + font-size: 8.5px; font-weight: 600; letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1; + cursor: pointer; + white-space: nowrap; + transition: color var(--t-fast), background var(--t-fast), opacity var(--t-fast); +} +.daw-panel-toggle:hover { background: var(--panel-2); } +/* Legible means the panel is there, struck through means it is put away. The + other way round -- highlighting the hidden ones -- inverts what a pressed + toggle normally looks like, and makes the default state the loud one. */ +.daw-panel-toggle[aria-pressed="true"] { color: var(--fg-2); } +.daw-panel-toggle[aria-pressed="false"] { + color: var(--muted); + opacity: 0.6; + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +.app.panel-analysis-off .daw-track-header { display: none; } +.app.panel-sections-off .daw-section-ribbon { display: none; } +.app.panel-timeline-off .footer-wave-region { display: none; } + /* ── Waveform header ── */ .daw-wave-header { display: flex; @@ -1805,9 +1884,17 @@ input, textarea { font-family: inherit; } display: flex; flex-direction: column; justify-content: center; - gap: 5px; + gap: var(--lane-name-vu-gap, 5px); width: 58px; flex-shrink: 0; + /* Centres the NAME on the row, not the name-and-meter pair. The row centres + its children, so a stacked pair puts the name above the centre line by half + the meter plus the gap -- and the waveform beside it is centred on that + line, so the label reads as sitting too high. A top margin is centred with + the item, so this offsets the pair by exactly half of it and the name comes + level with its own waveform. Cheap and reversible: the meter simply moves + down with it. */ + margin-top: calc(var(--lane-vu-h, 10px) + var(--lane-name-vu-gap, 5px)); } /* Stem icon — hidden per user preference */ @@ -1875,9 +1962,11 @@ input, textarea { font-family: inherit; } /* VU meter — sits below stem name in .lane-left-col, spans full column width */ /* 5px read as a hairline rather than a meter: at that height the gradient had nowhere to show and a moving level was hard to see at a glance. */ +/* Height shared with .lane-name-vu's centring offset, so the two cannot drift. */ +:root { --lane-vu-h: 10px; } .lane-vu.mx-meter { position: relative; - height: 10px; + height: var(--lane-vu-h, 10px); width: 100%; background: var(--bg); border: 1px solid var(--border); @@ -2045,10 +2134,20 @@ input, textarea { font-family: inherit; } .daw.engine-waveforms .stem-waveform-layer { display: flex !important; } -/* waves-column must size naturally now that multitrack is in flow */ +/* The column is exactly the stack _applyLaneHeight computed, never whatever the + multitrack happens to be. Letting the multitrack size it looks reasonable and + is where the misalignment came from: its lane height is fixed when the tracks + are created, so after a resize the column keeps the old size while the mixer + rows beside it follow the new one, and the two walk apart down the stack. + Measured at 1600x768 after a resize from 900: the column stayed 498px against + a 432px stack, so waveform rows sat 83px apart against the mixer's 72 and the + last pair was 55px out of line. + + The mixer stack is count * --lane-h and this is count * --lane-h, so they + agree by construction rather than by coincidence. */ .daw .waves-column { - height: auto !important; - min-height: 0 !important; + height: var(--wave-widget-track-stack-h, auto) !important; + min-height: var(--wave-widget-track-stack-h, 0) !important; } .loop-region.hidden { display: none !important; } diff --git a/static/index.html b/static/index.html index 848675a4..7d9f58c8 100644 --- a/static/index.html +++ b/static/index.html @@ -29,6 +29,7 @@
+
@@ -122,6 +123,34 @@
+ +
+ Click to collapse + + + + + +
+
+