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
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cut-as-code-editor",
"version": "0.1.0+codex.20260823221007",
"version": "0.1.0+codex.20260924000100",
"description": "Local Cut as Code video project review and editing runtime.",
"author": {
"name": "WhiteTowerAI",
Expand Down
5 changes: 3 additions & 2 deletions runtime/protocol_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ def audio_index(clip_id):
source = video(command["clip_id"])
if source.get("audio_mode", "embedded") == "detached":
raise ValueError("audio is already detached")
muted = source.get("audio_mode") == "muted"
source["audio_mode"] = "detached"
audio_clips.append({
"id": f"{source['id']}:audio",
Expand All @@ -777,7 +778,7 @@ def audio_index(clip_id):
"speed": source.get("speed", 1.0),
"source_video_clip_id": source["id"],
"linked": True,
"muted": False,
"muted": muted,
**({"source_asset_id": source["source_asset_id"]} if source.get("source_asset_id") else {}),
})
elif command_type == "attach-audio":
Expand All @@ -786,7 +787,7 @@ def audio_index(clip_id):
if source.get("audio_mode") != "detached" or len(matches) != 1 or not matches[0].get("linked"):
raise ValueError("only linked detached audio can be attached")
audio_clips.remove(matches[0])
source["audio_mode"] = "embedded"
source["audio_mode"] = "muted" if matches[0].get("muted") else "embedded"
elif command_type == "mute-video-audio":
source = video(command["clip_id"])
if source.get("audio_mode") == "detached" or not isinstance(command["muted"], bool):
Expand Down
26 changes: 26 additions & 0 deletions runtime/tests/test_protocol_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,32 @@ def test_timeline_edit_deletes_and_restores_the_final_clip(self):
self.assertEqual(["clip-1"], [clip["id"] for clip in timeline["clips"]])
self.assertEqual({"start_s": 0.0, "end_s": 1.0}, timeline["clips"][0]["program_range"])

def test_audio_mute_survives_detach_attach_and_reload(self):
self._configure_cut_project()
opened = self.service.handle_request({"verb": "open_project", "project_root": str(self.root)})
snapshot = opened["snapshot"]
for command, mode, muted in [
({"type": "mute-video-audio", "clip_id": "clip-1", "muted": True}, "muted", None),
({"type": "detach-audio", "clip_id": "clip-1"}, "detached", True),
({"type": "attach-audio", "clip_id": "clip-1"}, "muted", None),
({"type": "mute-video-audio", "clip_id": "clip-1", "muted": False}, "embedded", None),
({"type": "detach-audio", "clip_id": "clip-1"}, "detached", False),
]:
with self.subTest(command=command):
result = self.service.handle_request({
"verb": "timeline.edit", "project_id": opened["project_id"],
"read_set": self._timeline_read_set(snapshot), "command": command,
})
self.assertTrue(result["ok"], result)
reloaded = self.service.handle_request({"verb": "open_project", "project_root": str(self.root)})
snapshot = reloaded["snapshot"]
timeline = json.loads(self.timeline.read_text(encoding="utf-8"))
self.assertEqual(mode, timeline["clips"][0]["audio_mode"])
if muted is not None:
self.assertEqual(muted, timeline["audio_clips"][0]["muted"])
else:
self.assertEqual([], timeline.get("audio_clips", []))

def test_detached_audio_commands_are_atomic_and_linked_edits_sync(self):
self._configure_cut_project()
timeline = json.loads(self.timeline.read_text(encoding="utf-8"))
Expand Down
4 changes: 2 additions & 2 deletions skills/video-understand/scripts/render_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ def _build(plan, project_root, plan_dir):
audio_chain = "" if abs(speed - 1.0) < 1e-9 else atempo_chain(speed) + ","
label = f"timeline-audio-{segment_index}"
graph.append(
f"[{next_input}:a:0]{audio_chain}aresample=48000,asetpts=PTS-STARTPTS+"
f"{float(segment['program_range']['start_s']):.9f}/TB[{label}]"
f"[{next_input}:a:0]{audio_chain}aresample=48000,asetpts=PTS-STARTPTS,"
f"adelay={round(float(segment['program_range']['start_s']) * 48000)}S:all=1[{label}]"
)
segment_labels.append(label)
next_input += 1
Expand Down
19 changes: 19 additions & 0 deletions skills/video-understand/tests/test_render_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ def test_detached_audio_renders_once_without_embedded_audio_doubling(self):
self.assertTrue(output.is_file())
self.assertLess(abs(_mean_volume(output) - _mean_volume(source)), 1.5)

audio = timeline["audio_clips"][0]
audio.update({"linked": False, "source_range": {"start_s": 0, "end_s": 0.25},
"program_range": {"start_s": 0.5, "end_s": 0.75}})
(root / "work" / "timeline.json").write_text(json.dumps(timeline), encoding="utf-8")
render_project.render(plan, root)
self.assertLess(max(map(abs, _audio_samples(output, 0.05, 0.2))), 10)
self.assertGreater(max(map(abs, _audio_samples(output, 0.55, 0.15))), 1000)

audio["muted"] = True
(root / "work" / "timeline.json").write_text(json.dumps(timeline), encoding="utf-8")
render_project.render(plan, root)
self.assertLess(max(map(abs, _audio_samples(output, 0.55, 0.15))), 10)

timeline["audio_clips"] = []
timeline["clips"][0]["audio_mode"] = "muted"
(root / "work" / "timeline.json").write_text(json.dumps(timeline), encoding="utf-8")
render_project.render(plan, root)
self.assertLess(max(map(abs, _audio_samples(output, 0.1))), 10)

def test_overlay_editor_transform_scales_and_positions_from_normalized_center(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
Expand Down
2 changes: 1 addition & 1 deletion ui/src/editor/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ export function projectFromSnapshot(base: EditorProjectView | null, snapshot: Ru
muted: clip.muted,
implicit: false,
})) ?? []
const implicitAudioClips = hasAudio ? videoClips.filter((clip) => !detachedAudioIds.has(clip.id)).map((clip) => ({
const implicitAudioClips = hasAudio ? videoClips.filter((clip) => clip.audioMode !== 'detached' && !detachedAudioIds.has(clip.id)).map((clip) => ({
...clip,
id: `${clip.id}:embedded-audio`,
trackId: 'track-audio',
Expand Down
67 changes: 48 additions & 19 deletions ui/src/editor/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
trimAudioSourceAtProgramDelta,
trimSourceAtProgramDelta,
moveAudioStartAtProgramDelta,
type TimelineEditCommand,
type TimelineTrimEdge,
} from './timeline-edit'

Expand Down Expand Up @@ -244,6 +245,7 @@ function Clip({
trimming,
onTrimStart,
onAudioMoveStart,
onMute,
onContextMenu,
}: {
track: TrackView
Expand All @@ -261,6 +263,7 @@ function Clip({
trimming: TimelineTrimEdge | null
onTrimStart: (event: PointerEvent<HTMLButtonElement>, clip: ClipView, edge: TimelineTrimEdge, kind?: 'video' | 'audio') => void
onAudioMoveStart: (event: PointerEvent<HTMLButtonElement>, clip: ClipView) => void
onMute: (clip: ClipView) => void
onContextMenu: (event: ReactMouseEvent<HTMLButtonElement>, track: TrackView, clip: ClipView) => void
}) {
const kind = track.kind
Expand Down Expand Up @@ -298,9 +301,11 @@ function Clip({
className={`timeline-clip timeline-clip--${kind}`}
data-timeline-clip={id}
{...(kind === 'audio' && !clip.implicit ? { 'data-linked': String(Boolean(clip.linked)) } : {})}
data-muted={kind === 'audio' ? Boolean(clip.muted) : undefined}
aria-label={`${track.name} ${kind === 'video' || kind === 'audio' ? 'clip' : 'cue'}`}
aria-pressed={selected}
onPointerDown={() => select({ kind, id })}
onClick={() => select({ kind, id })}
onPointerDownCapture={(event) => {
if (kind === 'audio' && !clip.implicit && !clip.linked && event.button === 0) onAudioMoveStart(event, clip)
}}
Expand Down Expand Up @@ -392,11 +397,17 @@ function Clip({
><span aria-hidden /></button>
</>
)}
{kind === 'audio' && !clip.implicit && (
<span className="timeline-audio-state" aria-hidden>
{clip.linked ? <Link size={12} /> : <Link2Off size={12} />}
{clip.muted ? <VolumeX size={12} /> : null}
</span>
{kind === 'audio' && clip.implicit !== undefined && (
<div className="timeline-audio-state">
<span title={clip.implicit ? 'Original video audio' : clip.linked ? 'Linked to video' : 'Independent audio'}>
{clip.linked ? <Link aria-hidden size={12} /> : <Link2Off aria-hidden size={12} />}
</span>
<button type="button" className="timeline-audio-mute" aria-label={clip.muted ? 'Unmute audio' : 'Mute audio'}
title={clip.muted ? 'Unmute audio' : 'Mute audio'} aria-pressed={Boolean(clip.muted)} disabled={!editable}
onPointerDown={(event) => event.stopPropagation()} onClick={() => onMute(clip)}>
{clip.muted ? <VolumeX aria-hidden size={13} /> : <Volume2 aria-hidden size={13} />}
</button>
</div>
)}
{kind === 'video' && trimming && (
<output className={`timeline-trim-readout timeline-trim-readout--${trimming}`} aria-live="polite">
Expand Down Expand Up @@ -505,13 +516,27 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
const selectedVideo = selection?.kind === 'video'
? project?.tracks.find((track) => track.kind === 'video')?.clips?.find((clip) => clip.id === selection.id)
: undefined
const selectedAudio = selection?.kind === 'audio'
? project?.tracks.find((track) => track.kind === 'audio')?.clips?.find((clip) => clip.id === selection.id)
: undefined
const deleteCommand: TimelineEditCommand | undefined = selectedVideo
? { type: 'delete', clipId: selectedVideo.id }
: selectedAudio && !selectedAudio.implicit && !selectedAudio.linked
? { type: 'delete-audio', audioClipId: selectedAudio.id } : undefined
const splitEnabled = Boolean(
timelineEditable && selectedVideo && canSplitClip(project!, selectedVideo.id, currentTimeS) && !timelinePending,
)
const deleteEnabled = Boolean(
timelineEditable && selectedVideo && !timelinePending,
timelineEditable && deleteCommand && !timelinePending,
)

function toggleAudioMute(clip: ClipView) {
select({ kind: 'audio', id: clip.id })
if (clip.implicit && clip.linkedClipId) {
void editTimeline({ type: 'mute-video-audio', clipId: clip.linkedClipId, muted: !clip.muted })
} else void editTimeline({ type: 'mute-audio', audioClipId: clip.id, muted: !clip.muted })
}

useEffect(() => {
if (!deleteNotice) return
const timeout = window.setTimeout(() => setDeleteNotice(null), 5000)
Expand All @@ -536,22 +561,23 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
if (target?.matches('input, textarea, [contenteditable="true"]')) return
if (event.defaultPrevented || event.isComposing || event.altKey
|| target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="menu"], [role="dialog"]')) return
const modifier = event.ctrlKey || event.metaKey
if (modifier && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) void redoTimeline()
else void undoTimeline()
return
}
if ((event.key === 'Delete' || event.key === 'Backspace') && deleteEnabled && selectedVideo) {
if ((event.key === 'Delete' || event.key === 'Backspace') && !modifier && !event.repeat && deleteEnabled && deleteCommand) {
event.preventDefault()
void editTimeline({ type: 'delete', clipId: selectedVideo.id })
void editTimeline(deleteCommand)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [deleteEnabled, editTimeline, redoTimeline, selectedVideo, undoTimeline])
}, [deleteEnabled, editTimeline, redoTimeline, selectedVideo, selectedAudio, undoTimeline])

useEffect(() => {
if (!trimDrag || !project) return
Expand Down Expand Up @@ -1188,10 +1214,7 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
label: clip.muted ? 'Unmute audio' : 'Mute audio',
icon: clip.muted ? Volume2 : VolumeX,
disabled: !editable,
onSelect: () => {
if (clip.implicit && video) void editTimeline({ type: 'mute-video-audio', clipId: video.id, muted: !clip.muted })
else void editTimeline({ type: 'mute-audio', audioClipId: clip.id, muted: !clip.muted })
},
onSelect: () => toggleAudioMute(clip),
},
{
id: 'copy-audio-range',
Expand Down Expand Up @@ -1354,7 +1377,7 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
<section className={`timeline-panel${hasMedia ? '' : ' timeline-panel--empty'}`} role="region" aria-label="Timeline">
<header className="timeline-toolbar" aria-label="Timeline tools">
{!runtime && <button type="button" aria-label="Add track" disabled={!hasMedia}><Plus aria-hidden size={18} /></button>}
<button className={snapEnabled ? '' : 'is-active'} type="button" aria-label="Select tool" title="Select and position timeline items" aria-pressed={!snapEnabled} onClick={() => setSnapEnabled(false)}><MousePointer2 aria-hidden size={18} /></button>
<button className="is-active" type="button" aria-label="Select tool" title="Select and position timeline items" aria-pressed={true}><MousePointer2 aria-hidden size={18} /></button>
<button className={snapEnabled ? 'is-active' : ''} type="button" aria-label="Toggle snap" title={snapEnabled ? 'Magnetic snapping on' : 'Magnetic snapping off'} aria-pressed={snapEnabled} onClick={() => setSnapEnabled(!snapEnabled)}><Magnet aria-hidden size={18} /></button>
{(hasMedia || timelinePast.length > 0 || timelineFuture.length > 0) && (
<>
Expand All @@ -1368,14 +1391,19 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
{!runtime && <DisabledTimelineCommand name="Duplicate" descriptionId="timeline-duplicate-description"><Copy aria-hidden size={18} /></DisabledTimelineCommand>}
{!runtime && <DisabledTimelineCommand name="Copy" descriptionId="timeline-copy-description"><Copy aria-hidden size={18} /></DisabledTimelineCommand>}
{!runtime && <DisabledTimelineCommand name="Reorder tracks" descriptionId="timeline-reorder-description"><ArrowUpDown aria-hidden size={18} /></DisabledTimelineCommand>}
<button type="button" aria-label="Delete clip" title={timelineEditable ? 'Delete selected clip (Delete)' : 'This project has no editable cut operation'} disabled={!deleteEnabled} onClick={() => selectedVideo && void editTimeline({ type: 'delete', clipId: selectedVideo.id })}><Trash2 aria-hidden size={18} /></button>
<button type="button" aria-label="Delete clip" title={timelineEditable ? selectedAudio ? 'Ripple delete audio (Delete)' : 'Delete selected clip (Delete)' : 'This project has no editable cut operation'} disabled={!deleteEnabled} onClick={() => deleteCommand && void editTimeline(deleteCommand)}><Trash2 aria-hidden size={18} /></button>
</>
)}
{timelinePending && <span className="timeline-edit-status" role="status">Saving timeline…</span>}
{!timelinePending && timelineError && <span className="timeline-edit-status timeline-edit-status--error" role="alert" title={timelineError}>{timelineError}</span>}
<span className="timeline-toolbar-spacer" />
<button type="button" aria-label="Fit timeline" title="Fit timeline to available width" onClick={() => setTimelineZoom(1)}><Ruler aria-hidden size={20} /></button>
<button type="button" aria-label="Fit timeline" title="Fit timeline to available width" onClick={() => {
setTimelineZoom(1)
if (surfaceRef.current) surfaceRef.current.scrollLeft = 0
if (rulerScrollRef.current) rulerScrollRef.current.scrollLeft = 0
}}><Ruler aria-hidden size={20} /></button>
<button type="button" aria-label="Zoom out timeline" title="Zoom out timeline" disabled={timelineZoom <= MIN_ZOOM} onClick={() => setTimelineZoom(timelineZoom - ZOOM_STEP)}><ZoomOut aria-hidden size={20} /></button>
<output className="timeline-zoom-level" aria-label="Timeline zoom">{Math.round(timelineZoom * 100)}%</output>
<button type="button" aria-label="Zoom in timeline" title="Zoom in timeline" disabled={timelineZoom >= MAX_ZOOM} onClick={() => setTimelineZoom(timelineZoom + ZOOM_STEP)}><ZoomIn aria-hidden size={20} /></button>
</header>
<div className="timeline-body">
Expand Down Expand Up @@ -1498,13 +1526,14 @@ export function TimelinePanel({ store }: TimelinePanelProps) {
presentationInsetPx={presentationInsetPx}
selection={selection}
select={select}
editable={timelineEditable && (
editable={timelineEditable && !timelinePending && (
track.kind === 'video'
|| (track.kind === 'audio' && !clip.implicit && !clip.linked)
|| track.kind === 'audio'
)}
trimming={trimDrag?.clipId === clip.id ? trimDrag.edge : null}
onTrimStart={startTrim}
onAudioMoveStart={startAudioMove}
onMute={toggleAudioMute}
onContextMenu={openClipContextMenu}
/>
))}
Expand Down
Loading
Loading