hisui 2025.1.0

Recording Composition Tool Hisui
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
use std::{num::NonZeroUsize, path::PathBuf};

use orfail::OrFail;
use shiguredo_openh264::Openh264Library;

use crate::{
    composer::{ComposeResult, Composer},
    encoder_libvpx,
    layout::Layout,
    metadata::RecordingMetadata,
    types::CodecName,
    video::FrameRate,
};

pub fn run(args: noargs::RawArgs) -> noargs::Result<()> {
    let args = Args::parse(args)?;
    if let Some(help) = args.get_help() {
        print!("{help}");
        return Ok(());
    }
    Runner::new(args).run().or_fail()?;
    Ok(())
}

#[derive(Debug, Clone)]
pub struct Args {
    pub help: Option<String>,
    pub in_metadata_file: Option<PathBuf>,
    pub out_video_codec: Option<CodecName>,
    pub out_audio_codec: Option<CodecName>,
    pub out_video_frame_rate: Option<FrameRate>,
    pub out_file: Option<PathBuf>,
    pub out_stats_file: Option<PathBuf>,
    pub max_columns: NonZeroUsize,
    pub libvpx_cq_level: usize,
    pub libvpx_min_q: usize,
    pub libvpx_max_q: usize,
    pub out_opus_bit_rate: NonZeroUsize,
    pub out_aac_bit_rate: NonZeroUsize,
    pub openh264: Option<PathBuf>,
    pub audio_only: bool,
    pub show_progress_bar: bool,
    pub layout: Option<PathBuf>,
    pub worker_threads: NonZeroUsize,
}

impl Args {
    pub fn parse(mut args: noargs::RawArgs) -> noargs::Result<Self> {
        let in_metadata_file = noargs::opt("in-metadata-file")
            .short('f')
            .ty("PATH")
            .example("/path/to/report-$RECORDING_ID.json")
            .doc(
                r#"Sora が生成した録画メタデータファイルを指定して合成を実行します

録画メタデータファイル指定時には、以下のレイアウトで合成が行われます:
{
  "resolution": ${ セルサイズを 320x240 として自動で計算 },
  "audio_sources": [ ${ 録画メタデータ内の全てのソース } ],
  "video_layout": {"main": {
    "max_columns": [ ${ `--max-columns` 引数の値 } ],
    "video_sources": [ ${ 録画メタデータ内の全てのソース } ],
    "border_pixels": 0
  }}
}

NOTE: `--layout` 引数が指定されている場合にはこの引数は無視されます"#,
            )
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;
        let layout = noargs::opt("layout")
            .ty("PATH")
            .doc("Hisui のレイアウトファイルを指定して合成を実行します")
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;
        let out_file = noargs::opt("out-file")
            .ty("PATH")
            .doc(concat!(
                "合成結果を保存するファイルのパス\n",
                "\n",
                "この引数が未指定の場合には、 `--in-metadata-file` ないし `--layout` 引数で\n",
                "指定した入力ファイルと同じディレクトリに `output.mp4` という名前で保存されます\n",
                "(ただし、音声のみの場合には `output.mp4a` という名前になります)",
            ))
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;
        let out_video_codec = noargs::opt("out-video-codec")
            .ty("VP8|VP9|H264|H265|AV1")
            .doc(concat!(
                "映像のエンコードコーデック [default: VP9]\n",
                "\n",
                "なお「この引数が未指定」かつ「--layout 引数が指定されている」かつ",
                "「`video_codec` がレイアウトで指定されている」場合には、その値が使われます"
            ))
            .take(&mut args)
            .present_and_then(|a| CodecName::parse_video(a.value()))?;
        let out_audio_codec = noargs::opt("out-audio-codec")
            .ty("Opus|AAC")
            .doc(concat!(
                "音声のエンコードコーデック [default: Opus]\n",
                "\n",
                "なお「この引数が未指定」かつ「--layout 引数が指定されている」かつ",
                "「`audio_codec` がレイアウトで指定されている」場合には、その値が使われます\n",
                "\n",
                "また AAC は以下の場合にのみ利用可能です:\n",
                "  - macOS\n",
                "  - FDK-AAC を有効にして自前ビルドした Hisui (`--feature fdk-aac`)\n",
            ))
            .take(&mut args)
            .present_and_then(|a| CodecName::parse_audio(a.value()))?;

        let out_video_frame_rate = noargs::opt("out-video-frame-rate")
            .ty("INTEGER|RATIONAL")
            .doc(concat!(
                "合成後の映像のフレームーレート [default: 25]\n",
                "\n",
                "なお「この引数が未指定」かつ「--layout 引数が指定されている」かつ",
                "「`frame_rate` がレイアウトで指定されている」場合には、その値が使われます"
            ))
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;
        let max_columns = noargs::opt("max-columns")
            .ty("POSITIVE_INTEGER")
            .default("3")
            .doc(concat!(
                "入力映像を配置するグリッドの最大カラム数\n",
                "(レイアウトファイルの \"max_column\" フィールドに対応する引数)\n",
                "\n",
                "NOTE: `--layout` 引数指定時には、この引数は無視されます"
            ))
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let audio_only = noargs::flag("audio-only")
            .doc(concat!(
                "音声のみを合成対象にします\n",
                "\n",
                "NOTE: `--layout` 引数指定時には、この引数は無視されます\n"
            ))
            .take(&mut args)
            .is_present();
        let openh264 = noargs::opt("openh264")
            .ty("PATH")
            .doc(concat!(
                "OpenH264 の共有ライブラリのパス\n",
                "\n",
                "この引数を指定すると OpenH264 を用いて H.264 の",
                "エンコードとデコードが行われるようになります\n",
                "NOTE: H.264 を扱える他のエンジンが存在する場合でも OpenH264 が優先されます\n"
            ))
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;
        let libvpx_cq_level = noargs::opt("libvpx-cq-level")
            .ty("NON_NEGATIVE_INTEGER")
            .default(encoder_libvpx::DEFAULT_CQ_LEVEL)
            .doc(concat!(
                "libvpx のエンコードパラメータ\n",
                "\n",
                "`vpx_codec_control_(..., VP8E_SET_CQ_LEVEL, ...)` ",
                "関数呼び出しの引数として渡されます\n",
                "\n",
                "なお「--layout 引数が指定されている」かつ",
                "「`libvpx_vp{8,9}_encode_params` がレイアウトで指定されている」場合には、",
                "この引数は無視されます"
            ))
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let libvpx_min_q = noargs::opt("libvpx-min-q")
            .ty("NON_NEGATIVE_INTEGER")
            .default(encoder_libvpx::DEFAULT_MIN_Q)
            .doc(concat!(
                "libvpx のエンコードパラメータ\n",
                "\n",
                "`vpx_codec_enc_cfg` 構造体の `rc_min_quantizer` に設定されます\n",
                "\n",
                "なお「--layout 引数が指定されている」かつ",
                "「`libvpx_vp{8,9}_encode_params` がレイアウトで指定されている」場合には、",
                "この引数は無視されます"
            ))
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let libvpx_max_q = noargs::opt("libvpx-max-q")
            .ty("NON_NEGATIVE_INTEGER")
            .default(encoder_libvpx::DEFAULT_MAX_Q)
            .doc(concat!(
                "libvpx のエンコードパラメータ\n",
                "\n",
                "`vpx_codec_enc_cfg` 構造体の `rc_max_quantizer` に設定されます\n",
                "\n",
                "なお「--layout 引数が指定されている」かつ",
                "「`libvpx_vp{8,9}_encode_params` がレイアウトで指定されている」場合には、",
                "この引数は無視されます"
            ))
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let out_opus_bit_rate = noargs::opt("out-opus-bit-rate")
            .ty("BPS")
            .default("65536")
            .doc("Opus でエンコードする際のビットレート")
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let out_aac_bit_rate = noargs::opt("out-aac-bit-rate")
            .ty("BPS")
            .default("64000")
            .doc("AAC でエンコードする際のビットレート")
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let show_progress_bar = noargs::opt("show-progress-bar")
            .ty("true|false")
            .default("true")
            .doc("true が指定された場合には合成の進捗を表示します")
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let worker_threads = noargs::opt("thread-count")
            .short('T')
            .ty("INTEGER")
            .default("1")
            .env("HISUI_THREAD_COUNT")
            .doc(concat!(
                "合成処理に使用するワーカースレッド数を指定します\n",
                "\n",
                "なおこれはあくまでも Hisui 自体が起動するスレッドの数であり、\n",
                "各エンコーダーやデコーダーが内部で起動するスレッドには関与しません",
            ))
            .take(&mut args)
            .then(|a| a.value().parse())?;
        let out_stats_file = noargs::opt("out-stats-file")
            .ty("PATH")
            .doc("合成実行中に集めた統計情報 JSON の出力先ファイル")
            .take(&mut args)
            .present_and_then(|a| a.value().parse())?;

        // 以降は legacy 版のみが対応している引数群
        // (当面は残しておいて、どこかの段階で引数自体を削除する)
        if noargs::flag("video-codec-engines")
            .doc("OBSOLETE: 2025.1.0 以降では指定しても無視されます")
            .take(&mut args)
            .is_present()
        {
            // まだロガーのセットアップが行われていないので eprintln!() で直接出力する
            // (以降も同様)
            eprintln!(
                "[WARN] `--video-codec-engines` is obsolete (please use `list-codecs` command instead)\n"
            );
        }
        if noargs::opt("mp4-muxer")
            .ty("IGNORED")
            .doc("OBSOLETE: 2025.1.0 以降では指定しても無視されます")
            .take(&mut args)
            .is_present()
        {
            eprintln!("[WARN] `--mp4-muxer` is obsolete\n");
        }
        if noargs::opt("dir-for-faststart")
            .ty("IGNORED")
            .doc("OBSOLETE: 2025.1.0 以降では指定しても無視されます")
            .take(&mut args)
            .is_present()
        {
            eprintln!("[WARN] `--dir-for-faststart` is obsolete\n");
        }
        if noargs::opt("out-container")
            .ty("IGNORED")
            .doc("OBSOLETE: 2025.1.0 以降では指定しても無視されます")
            .take(&mut args)
            .is_present()
        {
            eprintln!("[WARN] `--out-container` is obsolete\n");
        }
        if noargs::opt("h264-encoder")
            .ty("IGNORED")
            .doc("OBSOLETE: 2025.1.0 以降では指定しても無視されます")
            .take(&mut args)
            .is_present()
        {
            eprintln!("[WARN] `--h264-encoder` is obsolete\n");
        }

        if in_metadata_file.is_none() && layout.is_none() {
            // 最低限必要な引数が指定されていない場合にはヘルプを表示する
            args.metadata_mut().help_mode = true;
        }

        Ok(Self {
            in_metadata_file,
            layout,
            out_file,
            out_video_codec,
            out_audio_codec,
            out_video_frame_rate,
            max_columns,
            audio_only,
            openh264,
            libvpx_cq_level,
            libvpx_min_q,
            libvpx_max_q,
            out_opus_bit_rate,
            out_aac_bit_rate,
            show_progress_bar,
            worker_threads,
            out_stats_file,
            help: args.finish()?,
        })
    }

    pub fn get_help(&self) -> Option<&String> {
        self.help.as_ref()
    }
}

#[derive(Debug)]
pub struct Runner {
    args: Args,
}

impl Runner {
    pub fn new(args: Args) -> Self {
        Self { args }
    }

    pub fn run(&mut self) -> orfail::Result<()> {
        // レイアウトを準備
        let mut layout = self.create_layout().or_fail()?;
        log::debug!("layout: {layout:?}");

        if let Some(codec) = self.args.out_video_codec {
            layout.video_codec = codec;
        }
        if let Some(codec) = self.args.out_audio_codec {
            layout.audio_codec = codec;
        }
        if let Some(frame_rate) = self.args.out_video_frame_rate {
            layout.frame_rate = frame_rate;
        }
        layout.audio_bitrate = Some(match layout.audio_codec {
            CodecName::Aac => self.args.out_aac_bit_rate,
            CodecName::Opus => self.args.out_opus_bit_rate,
            codec => {
                return Err(orfail::Failure::new(format!(
                    "unsupported audio codec: {codec:?}"
                )));
            }
        });

        // レガシーでは引数のエンコードパラメータをレイアウトで指定されたものよりも優先する
        layout.encode_params.libvpx_vp8.max_quantizer = self.args.libvpx_max_q;
        layout.encode_params.libvpx_vp8.min_quantizer = self.args.libvpx_min_q;
        layout.encode_params.libvpx_vp8.cq_level = self.args.libvpx_cq_level;

        layout.encode_params.libvpx_vp9.max_quantizer = self.args.libvpx_max_q;
        layout.encode_params.libvpx_vp9.min_quantizer = self.args.libvpx_min_q;
        layout.encode_params.libvpx_vp9.cq_level = self.args.libvpx_cq_level;

        // 必要に応じて openh264 の共有ライブラリを読み込む
        let openh264_lib =
            if let Some(path) = self.args.openh264.as_ref().filter(|_| layout.has_video()) {
                Some(Openh264Library::load(path).or_fail()?)
            } else {
                None
            };

        // 変換後のファイルのパスを決定
        let out_file_path = if let Some(path) = self.args.out_file.clone() {
            path
        } else if !layout.has_video() && layout.has_audio() {
            layout.base_path.join("output.mp4a")
        } else {
            layout.base_path.join("output.mp4")
        };

        // Composer を作成して設定
        let mut composer = Composer::new(layout);
        composer.openh264_lib = openh264_lib;
        composer.show_progress_bar = self.args.show_progress_bar;
        composer.worker_threads = self.args.worker_threads;
        composer.stats_file_path = self.args.out_stats_file.clone();

        // 合成を実行
        let ComposeResult { stats: _, success } = composer.compose(&out_file_path).or_fail()?;

        if !success {
            // エラー発生時は終了コードを変える
            std::process::exit(1);
        }

        Ok(())
    }

    fn create_layout(&self) -> orfail::Result<Layout> {
        if let Some(layout_file_path) = &self.args.layout {
            let base_path = std::path::absolute(layout_file_path)
                .or_fail()?
                .parent()
                .or_fail()?
                .to_path_buf();
            Layout::from_layout_json_file(base_path, layout_file_path).or_fail()
        } else if let Some(report_file_path) = &self.args.in_metadata_file {
            let report = RecordingMetadata::from_file(report_file_path).or_fail()?;
            log::debug!("loaded recording report: {report:?}");
            Layout::from_recording_report(
                report_file_path,
                &report,
                self.args.audio_only,
                self.args.max_columns.get(),
            )
            .or_fail()
        } else {
            // 引数バリデーションによってここには来ない
            unreachable!()
        }
    }
}