@bycrux/editor 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -603,6 +603,17 @@ export const LABEL_FONT = '10px ui-sans-serif, system-ui, sans-serif'
603
603
  export const LABEL_PAD_PX = 6
604
604
  /** Below this width a label is more smear than information, so it is skipped. */
605
605
  export const MIN_LABEL_WIDTH_PX = 28
606
+ /** Muted-speaker glyph drawn at the head of a muted audio bar. Mute used to be
607
+ * carried by fill alone (`audioMutedFill` plus a dropped border), which reads
608
+ * as "a slightly different colour" rather than "this is off" — operators asked
609
+ * why a lane was silent while looking straight at the thing telling them. */
610
+ export const MUTED_ICON_SIZE_PX = 9
611
+ /** Gap between the glyph and the label that follows it. */
612
+ export const MUTED_ICON_GAP_PX = 4
613
+ /** Below this bar width even the glyph is dropped. Lower than
614
+ * `MIN_LABEL_WIDTH_PX` on purpose: on a bar too narrow to name, "is it muted"
615
+ * is the more valuable of the two answers, so the glyph outlives the label. */
616
+ export const MIN_MUTED_ICON_WIDTH_PX = 14
606
617
  /** Middle-baseline offset from a clip's top edge — half the font's 10px plus a
607
618
  * 4px margin, so the label rides just inside the clip's top border. */
608
619
  export const LABEL_TOP_OFFSET_PX = 9
@@ -1375,6 +1386,57 @@ export interface AudioItemDrawArgs {
1375
1386
  hoveredFadeSide?: 'in' | 'out' | null
1376
1387
  }
1377
1388
 
1389
+ /**
1390
+ * A speaker with a cross beside it, drawn in a `size`×`size` box anchored at
1391
+ * (x, y). Filled for the speaker body and stroked for the cross, so it holds
1392
+ * up both against the washed-out muted fill and over whatever waveform the
1393
+ * content layer painted underneath.
1394
+ *
1395
+ * Deliberately geometry rather than a font glyph or an inline SVG: this runs
1396
+ * inside the timeline's canvas painter, where the only other text is drawn with
1397
+ * `LABEL_FONT`, and a missing icon font would fail as a blank box with no
1398
+ * fallback path to notice it.
1399
+ */
1400
+ function drawMutedGlyph(
1401
+ ctx: DrawContext,
1402
+ x: number,
1403
+ y: number,
1404
+ size: number,
1405
+ color: string,
1406
+ ): void {
1407
+ const s = size
1408
+ ctx.save()
1409
+ ctx.strokeStyle = color
1410
+ ctx.fillStyle = color
1411
+ // `DrawContext` is the painter's narrowed surface and carries no
1412
+ // `lineJoin`/`lineCap`; at 9px neither is visible anyway.
1413
+ ctx.lineWidth = 1
1414
+
1415
+ // Speaker: a small back plate, then the cone flaring out of it.
1416
+ const bodyW = s * 0.26
1417
+ const bodyH = s * 0.34
1418
+ const bodyX = x + s * 0.04
1419
+ const bodyY = y + (s - bodyH) / 2
1420
+ ctx.beginPath()
1421
+ ctx.moveTo(bodyX, bodyY)
1422
+ ctx.lineTo(bodyX + bodyW, bodyY)
1423
+ ctx.lineTo(x + s * 0.48, y + s * 0.14)
1424
+ ctx.lineTo(x + s * 0.48, y + s * 0.86)
1425
+ ctx.lineTo(bodyX + bodyW, bodyY + bodyH)
1426
+ ctx.lineTo(bodyX, bodyY + bodyH)
1427
+ ctx.closePath()
1428
+ ctx.fill()
1429
+
1430
+ // The cross — what actually says "off" at a glance.
1431
+ ctx.beginPath()
1432
+ ctx.moveTo(x + s * 0.62, y + s * 0.3)
1433
+ ctx.lineTo(x + s * 0.96, y + s * 0.7)
1434
+ ctx.moveTo(x + s * 0.96, y + s * 0.3)
1435
+ ctx.lineTo(x + s * 0.62, y + s * 0.7)
1436
+ ctx.stroke()
1437
+ ctx.restore()
1438
+ }
1439
+
1378
1440
  export function drawAudioItem(
1379
1441
  ctx: DrawContext,
1380
1442
  args: AudioItemDrawArgs,
@@ -1412,11 +1474,26 @@ export function drawAudioItem(
1412
1474
  if (fadeInPx > 0) drawFadeEnvelope(ctx, rect, 'in', fadeInPx, fadeSpanX, fadeSpanWidth, fadeInCurve, palette)
1413
1475
  if (fadeOutPx > 0) drawFadeEnvelope(ctx, rect, 'out', fadeOutPx, fadeSpanX, fadeSpanWidth, fadeOutCurve, palette)
1414
1476
 
1477
+ // Mute glyph leads, and the label starts after it, so the two can never
1478
+ // overlap on a narrow bar.
1479
+ const headX = rect.x + LABEL_PAD_PX + handleWidth
1480
+ let labelX = headX
1481
+ if (muted && rect.width >= MIN_MUTED_ICON_WIDTH_PX) {
1482
+ drawMutedGlyph(
1483
+ ctx,
1484
+ headX,
1485
+ rect.y + (rect.height - MUTED_ICON_SIZE_PX) / 2,
1486
+ MUTED_ICON_SIZE_PX,
1487
+ palette.colors.audioText,
1488
+ )
1489
+ labelX = headX + MUTED_ICON_SIZE_PX + MUTED_ICON_GAP_PX
1490
+ }
1491
+
1415
1492
  if (rect.width >= MIN_LABEL_WIDTH_PX) {
1416
1493
  ctx.fillStyle = palette.colors.audioText
1417
1494
  ctx.font = LABEL_FONT
1418
1495
  ctx.textBaseline = 'middle'
1419
- ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
1496
+ ctx.fillText(label, labelX, rect.y + rect.height / 2)
1420
1497
  }
1421
1498
  ctx.restore()
1422
1499
 
@@ -127,6 +127,25 @@ export function resolveAudioWindow(
127
127
  return { start, end: Math.max(horizon, start + AUDIO_FALLBACK_SPAN_SECONDS) }
128
128
  }
129
129
 
130
+ /**
131
+ * Furthest end across the project's unmuted audio tracks, resolving each
132
+ * track's window through `resolveAudioWindow` — so a track carrying no explicit
133
+ * `end` reports its natural length instead of collapsing to 0.
134
+ *
135
+ * Deliberately called with NO horizon: this feeds the value `contentDuration`
136
+ * is derived from, so passing one back in would be circular.
137
+ *
138
+ * Callers use this only as a LAST-RESORT transport ceiling, for a project with
139
+ * nothing visual in it. Audio stays out of the ceiling whenever there IS visual
140
+ * content — the canvas/video divergence over the audio tail is intentional and
141
+ * documented in timeline-core's `durations.js`.
142
+ */
143
+ export function audioEnd(project: Project): number {
144
+ return (project.audio?.tracks ?? [])
145
+ .filter(t => !t.muted)
146
+ .reduce((m, t) => Math.max(m, resolveAudioWindow(t).end), 0)
147
+ }
148
+
130
149
  /**
131
150
  * Group audio tracks into rendered lanes, in ascending lane order. Tracks
132
151
  * carrying an explicit `lane` keep it; the rest are auto-assigned lanes above