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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Current DVD authoring capabilities include:
- menu editing with authored scene documents, layers, still-image composition nodes, and motion menus with intro/loop timing, audio beds, and timeout actions
- animated button highlights authored as keyframe tracks on a timeline strip and compiled into the disc's subpicture stream
- semantic interaction graphs and remote-navigation preview simulation
- asset inspection with embedded metadata title surfacing, compatibility explanations, fix-oriented validation, and still-image imports for menu authoring
- asset inspection with embedded metadata title surfacing, compatibility explanations (including per-codec DVD-Video audio bitrate ceilings, not just codec-name matching), fix-oriented validation, and still-image imports for menu authoring
- DVD build planning and execution with diagnostics export and toolchain checks
- bitmap subtitle muxing plus first-pass text subtitle rendering for DVD authoring

Expand Down
8 changes: 4 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,8 @@ The native toolchain adapter should detect which encoders and pass-through paths
This matters especially for:

- MP2 output availability
- DTS output availability
- copy and passthrough eligibility for existing compliant streams
- DTS output availability — confirmed unavailable as a re-encode target regardless of environment: ffmpeg's only DTS encoder path produces a stream that decodes fine in isolation but corrupts once muxed through the DVD program-stream muxer, so re-encoding to DTS is refused outright at validation and build time. DTS remains available as a stream copy of an already-DTS source.
- copy and passthrough eligibility for existing compliant streams — a source stream is only copy-eligible when both its codec name _and_ its bitrate are DVD-legal (e.g. AC-3 above 448 kbps is a valid AC-3 bitstream but exceeds the DVD-Video ceiling and still requires a re-encode despite the codec name matching)

### 12.2.5 Planner integration

Expand Down Expand Up @@ -1102,7 +1102,7 @@ The app should not hide the build pipeline, but it also should not dump raw tool

- "Title 3 exceeds estimated disc budget by 412 MB after adding the second audio track."
- "Subtitle track 2 is text-based and must be converted before DVD authoring."
- "Requested DTS output is not available in the current toolchain configuration."
- "Cannot re-encode audio track \"English\" to DTS — ffmpeg has no reliable DTS encoder (its native encoder produces DVD-incompatible output). DTS is only supported as a stream copy of an already-DTS source; re-encode to AC3 instead."
- "Menu button 'Scenes' points to a missing chapter menu."

---
Expand Down Expand Up @@ -1514,7 +1514,7 @@ These should be resolved during design review.
6. Should the project file remain pure JSON in v1, or should a sidecar cache database be introduced immediately? — This affects portability, transparency, migration complexity, and how derived data is separated from authored data.
7. How should menu highlight/select assets be modelled internally to support both ease of use and DVD constraints? — This matters because button visuals are simple in the UI but have strict authored-disc implications.
8. How much of the generated build pipeline should be user-overridable? — This determines how inspectable and hackable the tool should be for advanced users without making the normal workflow fragile.
9. Should DTS output require a separate advanced-compatibility acknowledgement even when toolchain support is present? — This is about distinguishing legal authoring from broadly safe playback compatibility.
9. ~~Should DTS output require a separate advanced-compatibility acknowledgement even when toolchain support is present?~~ Narrowed by the confirmed-broken ffmpeg DTS encoder (§12.2.4): DTS re-encoding isn't offered at all, toolchain-independent, so the open question is now only about copy-mode DTS passthrough, which is already unrestricted since it never touches the broken encoder.
10. How aggressively should the app recommend Half-D1 over Full-D1 when bitrate pressure is high? — This affects how opinionated the planner becomes when trading resolution against expected quality.
11. How should autogenerated menus be regenerated after the user has manually customised them? — This raises important questions about preserving edits, reapplying templates, and avoiding destructive regeneration.
12. Should generated chapter, audio, and subtitle menus be opt-in per title or derived automatically from project rules? — This affects how much automation the app performs by default and how predictable the authored structure feels.
Expand Down
63 changes: 54 additions & 9 deletions apps/spindle/src/pages/TitlesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,11 +547,20 @@ function TitleEditor({
// Auto-map first video stream and create audio mappings
const videoMapping =
asset.videoStreams.length > 0
? { sourceStreamIndex: asset.videoStreams[0].index, copyMode: 'copy' as CopyMode }
? {
sourceStreamIndex: asset.videoStreams[0].index,
copyMode: (isVideoCopyCompatible(asset) ? 'copy' : 're-encode') as CopyMode,
}
: null;

// Both dimensions must hold for a legal stream copy: a DVD-legal
// codec name at an over-ceiling bitrate (e.g. 640 kbps AC-3) still
// needs a re-encode down to a DVD-legal rate.
const audioCompatByStream = new Map(
asset.compatibilityDetail?.audioStreams.map((c) => [c.streamIndex, c.codec.compatible]) ?? [],
asset.compatibilityDetail?.audioStreams.map((c) => [
c.streamIndex,
c.codec.compatible && c.bitrate.compatible,
]) ?? [],
);
const audioMappings = asset.audioStreams.map((as_, i) => {
const compatible = audioCompatByStream.get(as_.index) ?? false;
Expand Down Expand Up @@ -718,15 +727,23 @@ function TitleEditor({
<select
className="titles__select"
value={title.videoMapping?.sourceStreamIndex ?? ''}
onChange={(e) =>
onChange={(e) => {
const streamIndex = Number(e.target.value);
// Compatibility data only covers the inspected/primary
// stream (videoStreams[0]) — picking any other stream
// is data we have no compatibility signal for, so it
// must default to re-encode rather than assume copy.
const isPrimaryStream = streamIndex === selectedAsset.videoStreams[0]?.index;
onUpdate({
...title,
videoMapping: {
sourceStreamIndex: Number(e.target.value),
copyMode: 'copy',
sourceStreamIndex: streamIndex,
copyMode: (isPrimaryStream && isVideoCopyCompatible(selectedAsset)
? 'copy'
: 're-encode') as CopyMode,
},
})
}
});
}}
>
{selectedAsset.videoStreams.map((vs) => (
<option key={vs.index} value={vs.index}>
Expand Down Expand Up @@ -819,7 +836,13 @@ function TitleEditor({
<option value="AC3">AC3 (Dolby Digital)</option>
<option value="LPCM">LPCM</option>
<option value="MP2">MP2</option>
<option value="DTS">DTS</option>
<option
value="DTS"
disabled={am.copyMode !== 'copy'}
title="ffmpeg has no reliable DTS encoder — DTS is only supported as a passthrough of an already-DTS source, not as a re-encode target."
Comment on lines +839 to +842

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset DTS before implicit re-encoding

When an already-DTS track is in copy mode, selecting a channel layout or bitrate still changes copyMode to re-encode without changing outputTarget. Disabling DTS only in this codec picker does not cover those implicit transitions, so the new backend refusal makes the resulting project fail validation and build. Apply the same DTS-to-AC3 fallback to those controls, or disable them while DTS passthrough is selected.

Useful? React with 👍 / 👎.

>
DTS{am.copyMode !== 'copy' ? ' (passthrough only)' : ''}
Comment on lines +839 to +844

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new audio compatibility behaviour

This changes user-facing behaviour by making DTS passthrough-only and automatically switching incompatible audio and video streams to re-encode, but the commit updates neither README.md nor SPEC.md. Document the new restrictions and automatic defaults in both required files so the supported authoring workflow matches the application.

AGENTS.md reference: AGENTS.md:L89-L92

Useful? React with 👍 / 👎.

</option>
</select>
<select
className="titles__select"
Expand All @@ -838,6 +861,14 @@ function TitleEditor({
? {
...a,
copyMode,
// DTS re-encode isn't offered (no reliable ffmpeg
// encoder) — switching to Re-encode with DTS still
// selected must fall back rather than silently keep
// an option the UI no longer lets you pick.
outputTarget:
copyMode === 're-encode' && a.outputTarget === 'DTS'
? 'AC3'
: a.outputTarget,
// Channel layout and bitrate only apply to re-encoded
// tracks — clear them so a stale selection doesn't
// silently reappear if the user switches back later.
Expand Down Expand Up @@ -1238,7 +1269,21 @@ function isCopyCompatible(asset: Asset | null, mapping: AudioTrackMapping): bool
const compat = asset?.compatibilityDetail?.audioStreams.find(
(c) => c.streamIndex === mapping.sourceStreamIndex,
);
return compat?.codec.compatible ?? true;
if (!compat) return true;
// Both dimensions must hold: a DVD-legal codec at an over-ceiling
// bitrate (e.g. 640 kbps AC-3) still isn't legally copyable as-is.
return compat.codec.compatible && compat.bitrate.compatible;
}

// Unlike audio (a single per-stream codec check), video compatibility is
// multi-dimensional and unindexed (`CompatibilityDetail.video` assesses
// whichever stream inspection treated as primary — in practice always
// videoStreams[0]) — codec, resolution, AND frame rate must all already be
// DVD-legal for a stream copy to be valid; any one mismatch means re-encode.
function isVideoCopyCompatible(asset: Asset | null): boolean {
const compat = asset?.compatibilityDetail?.video;
if (!compat) return true;
return compat.codec.compatible && compat.resolution.compatible && compat.frameRate.compatible;
}

function sourceChannelLabel(asset: Asset | null, mapping: AudioTrackMapping): string | null {
Expand Down
1 change: 1 addition & 0 deletions plugins/tauri-plugin-spindle-project/guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ export interface VideoCompatibility {
export interface AudioStreamCompatibility {
streamIndex: number;
codec: PropertyCheck;
bitrate: PropertyCheck;
}

export interface ContainerCompatibility {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// ISO 639 language code normalisation for dvdauthor subpicture stream
// ISO 639 language code normalisation for dvdauthor audio/subpicture stream
// declarations.
//
// (c) Copyright 2026 Liminal HQ, Scott Morris
// SPDX-License-Identifier: MIT

use isolang::Language;

pub(super) fn dvdauthor_subpicture_language(language: &str) -> Option<String> {
pub(super) fn dvdauthor_language_code(language: &str) -> Option<String> {
let normalised = language
.trim()
.split(['-', '_'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// (c) Copyright 2026 Liminal HQ, Scott Morris
// SPDX-License-Identifier: MIT

use std::collections::HashMap;
use std::path::Path;

use crate::models::*;
Expand All @@ -29,6 +30,7 @@ pub(crate) fn generate_dvdauthor_xml(
VideoStandard::Ntsc => "ntsc",
VideoStandard::Pal => "pal",
};
let assets: HashMap<&str, &Asset> = project.assets.iter().map(|a| (a.id.as_str(), a)).collect();

let mut xml = String::new();

Expand Down Expand Up @@ -113,6 +115,7 @@ pub(crate) fn generate_dvdauthor_xml(
titleset_index,
&project.disc,
titles_dir,
&assets,
)?;

xml.push_str(" </titleset>\n");
Expand Down
74 changes: 74 additions & 0 deletions plugins/tauri-plugin-spindle-project/src/build/authoring/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,80 @@ fn dvdauthor_xml_rejects_mixed_menu_aspects_within_one_section() {
.contains("Menus in the same DVD menu section must share one display aspect"));
}

#[test]
fn dvdauthor_xml_declares_audio_streams_in_authored_order() {
// Without explicit <audio> declarations, dvdauthor auto-detects audio
// streams from the vob itself, and its inferred physical-track order
// does not reliably match the order the streams actually appear in —
// observed in practice with a mixed AC3+MP2 title, where dvdauthor
// placed the second (MP2) stream at physical audio 0 regardless of
// which track was marked default. Declaring the order explicitly is
// the fix; this pins that the declared order matches audio_mappings.
let mut project = test_project();
project.disc.titlesets[0].titles[0]
.audio_mappings
.push(AudioTrackMapping {
id: "am-2".to_string(),
source_stream_index: 2,
output_target: AudioOutputTarget::Mp2,
copy_mode: CopyMode::ReEncode,
label: "Commentary".to_string(),
language: "eng".to_string(),
order_index: 1,
is_default: false,
channel_layout: None,
bitrate_bps: None,
});

let plan = generate_build_plan(&project, "/tmp/dvd_output", false).unwrap();

let ac3_pos = plan
.dvdauthor_xml
.find("<audio format=\"ac3\" lang=\"en\" />")
.expect("expected an ac3 audio declaration");
let mp2_pos = plan
.dvdauthor_xml
.find("<audio format=\"mp2\" lang=\"en\" />")
.expect("expected an mp2 audio declaration");
assert!(
ac3_pos < mp2_pos,
"expected the default (ac3) audio track declared before the \
non-default (mp2) track\n{}",
plan.dvdauthor_xml
);
}

#[test]
fn dvdauthor_xml_declares_copied_audio_using_the_actual_source_codec() {
// A CopyMode::Copy mapping passes the source stream's own bytes through
// unchanged (build_ffmpeg_transcode_command emits `-c:a copy`, ignoring
// output_target entirely) — but the UI lets output_target and copyMode
// be set independently, so a track can end up in copy mode with a
// stale output_target left over from before the user switched modes
// (e.g. was re-encoding AC3 -> MP2, then switched to copy). Declaring
// that stale output_target's format to dvdauthor would tell it a
// physical AC3 stream is MP2, which can make dvdauthor reject or
// mis-author the stream. The declared format must come from the
// source's real codec for copied mappings.
let mut project = test_project();
project.assets[0].audio_streams[0].codec = "ac3".to_string();
project.disc.titlesets[0].titles[0].audio_mappings[0].copy_mode = CopyMode::Copy;
project.disc.titlesets[0].titles[0].audio_mappings[0].output_target = AudioOutputTarget::Mp2;

let plan = generate_build_plan(&project, "/tmp/dvd_output", false).unwrap();

assert!(
plan.dvdauthor_xml.contains("<audio format=\"ac3\" lang=\"en\" />"),
"expected the copied stream declared using its real ac3 source codec, not the stale mp2 output_target\n{}",
plan.dvdauthor_xml
);
assert!(
!plan.dvdauthor_xml.contains("<audio format=\"mp2\""),
"must not declare the stale mp2 output_target for a copy-mode mapping\n{}",
plan.dvdauthor_xml
);
}

#[test]
fn dvdauthor_xml_normalises_subpicture_languages_for_dvdauthor() {
let mut project = test_project();
Expand Down
Loading
Loading