20260128
This commit is contained in:
parent
af4dfd3bb3
commit
1c702e01db
|
|
@ -0,0 +1 @@
|
|||
お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。 すいませんが、お客様のお名前からお願いいたします。 はい、私がテストテロとして、家族情報として、 生年月日の方は? はい、生年月日の方は、1980年の1月の1日です。 ということは、47歳ということでよろしいでしょうか。 はい。 現在のお勤め先はどこになりますでしょうか? IT系ですね。 IT系ですね。 はい。 すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。 年収が今700万くらいですね。 はい。 では、家族構成について、お客様、ご結婚はなされておりますか。 そうですね。 はい。 下の名前だけでいいので、奥様のお名前をお願いいたします。 花子です。 はい。奥様の生年月日を教えていただけますか。 1990年の1月の10日です。 ということは、現在37歳ということでよろしいでしょうか。 そうですね。 奥様は、ご勤めになられてますでしょうか。 そうですね。普通のサービス会ですけど、はい。 ざっくりでよろしいので、奥様の年収が600万くらいですね。 はい。 では続きまして、お子様の方はいらっしゃいますか。 はい、います。 何人いらっしゃいますか。 一人子だけです。 一人だけですね。 生年月日とかは。 2018年の3月の3日ですけど。 そうですか。 現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。 孤立です。 あ、孤立ですね。 ありがとうございます。
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"text": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。",
|
||||
"duration": 13.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_000.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "すいませんが、お客様のお名前からお願いいたします。",
|
||||
"duration": 5.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_001.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "はい、私がテストテロとして、家族情報として、",
|
||||
"duration": 11.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_002.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "生年月日の方は?",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_003.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "はい、生年月日の方は、1980年の1月の1日です。",
|
||||
"duration": 9.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_004.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "ということは、47歳ということでよろしいでしょうか。",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_005.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"text": "はい。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_006.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"text": "現在のお勤め先はどこになりますでしょうか?",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_007.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"text": "IT系ですね。",
|
||||
"duration": 2.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_008.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"text": "IT系ですね。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_009.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"text": "はい。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_010.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"text": "すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。",
|
||||
"duration": 6.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_011.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"text": "年収が今700万くらいですね。",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_012.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"text": "はい。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_013.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"text": "では、家族構成について、お客様、ご結婚はなされておりますか。",
|
||||
"duration": 7.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_014.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"text": "そうですね。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_015.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"text": "はい。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_016.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"text": "下の名前だけでいいので、奥様のお名前をお願いいたします。",
|
||||
"duration": 4.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_017.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"text": "花子です。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_018.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"text": "はい。奥様の生年月日を教えていただけますか。",
|
||||
"duration": 4.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_019.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"text": "1990年の1月の10日です。",
|
||||
"duration": 7.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_020.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"text": "ということは、現在37歳ということでよろしいでしょうか。",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_021.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"text": "そうですね。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_022.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"text": "奥様は、ご勤めになられてますでしょうか。",
|
||||
"duration": 4.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_023.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"text": "そうですね。普通のサービス会ですけど、はい。",
|
||||
"duration": 4.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_024.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"text": "ざっくりでよろしいので、奥様の年収が600万くらいですね。",
|
||||
"duration": 5.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_025.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"text": "はい。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_026.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"text": "では続きまして、お子様の方はいらっしゃいますか。",
|
||||
"duration": 4.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_027.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"text": "はい、います。",
|
||||
"duration": 2.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_028.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"text": "何人いらっしゃいますか。",
|
||||
"duration": 2.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_029.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"text": "一人子だけです。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_030.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"text": "一人だけですね。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_031.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"text": "生年月日とかは。",
|
||||
"duration": 2.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_032.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"text": "2018年の3月の3日ですけど。",
|
||||
"duration": 5.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_033.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"text": "そうですか。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_034.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"text": "現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。",
|
||||
"duration": 11.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_035.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"text": "孤立です。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_036.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"text": "あ、孤立ですね。",
|
||||
"duration": 1.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"audioPath": "public/dialogue/dialogue_seg_037.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"text": "ありがとうございます。",
|
||||
"duration": 3.0,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"audioPath": "public/dialogue/dialogue_seg_038.mp3",
|
||||
"brollKeyword": "Japanese interview"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"meta": {
|
||||
"title": "家族代表様の人生設計",
|
||||
"title": "テスト太郎様の人生設計",
|
||||
"currencyUnit": "万円",
|
||||
"duration": 180,
|
||||
"clientName": "家族代表"
|
||||
"clientName": "テスト太郎"
|
||||
},
|
||||
"charts": {
|
||||
"assetDepletion": {
|
||||
|
|
@ -1709,7 +1709,7 @@
|
|||
"text": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。 すいませんが、お客様のお名前からお願いいたします。",
|
||||
"insight": "ライフプランのヒアリング",
|
||||
"targetYear": 2024,
|
||||
"duration": 11.136,
|
||||
"duration": 10.416,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
|
|
@ -1735,7 +1735,7 @@
|
|||
"text": "家族情報として、 生年月日の方は?",
|
||||
"insight": "ライフプランのヒアリング",
|
||||
"targetYear": 2024,
|
||||
"duration": 3.0,
|
||||
"duration": 3.024,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
|
|
@ -1761,7 +1761,7 @@
|
|||
"text": "ということは、47歳ということでよろしいでしょうか。",
|
||||
"insight": "年齢の確認",
|
||||
"targetYear": 2024,
|
||||
"duration": 4.32,
|
||||
"duration": 3.888,
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
|
|
@ -1787,7 +1787,7 @@
|
|||
"text": "皆様、こんにちは!ライフプランニングを通じて、皆様の夢を叶えるお手伝いをさせていただきます。",
|
||||
"insight": "オープニング",
|
||||
"targetYear": 2024,
|
||||
"duration": 7.824,
|
||||
"duration": 7.08,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 0,
|
||||
|
|
@ -1800,7 +1800,7 @@
|
|||
"text": "本日は、テスト太郎様、花子様ご夫婦のライフプランについて一緒に考えていきたいと思います。",
|
||||
"insight": "家族構成",
|
||||
"targetYear": 2024,
|
||||
"duration": 7.92,
|
||||
"duration": 8.064,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
|
|
@ -1813,7 +1813,7 @@
|
|||
"text": "太郎様は47歳、花子様は37歳でいらっしゃいますね。息子様の年齢は7歳で、小学校は公立とのこと。教育プランも中学校から私立をご検討されているんですね。",
|
||||
"insight": "教育プラン",
|
||||
"targetYear": 2024,
|
||||
"duration": 14.328,
|
||||
"duration": 14.28,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
|
|
@ -1826,7 +1826,7 @@
|
|||
"text": "太郎様の年収は703万円、花子様の年収は368万円。合計で1071万円の収入がある一方で、年間支出は1038万円とのこと。現在の資産は852万円ですね。",
|
||||
"insight": "家計の現状",
|
||||
"targetYear": 2024,
|
||||
"duration": 15.768,
|
||||
"duration": 15.792,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
|
|
@ -1839,7 +1839,7 @@
|
|||
"text": "次に、今後のライフイベントについて見ていきましょう。",
|
||||
"insight": "ライフイベント",
|
||||
"targetYear": 2024,
|
||||
"duration": 4.488,
|
||||
"duration": 4.2,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2,
|
||||
|
|
@ -1852,7 +1852,7 @@
|
|||
"text": "2029年には車の交換、2032年には息子様が私立中学校へ入学されます。結婚記念日の海外旅行やリフォーム工事も予定されていますね。2053年で住宅ローンが完済するのは素晴らしいですね。",
|
||||
"insight": "イベントの詳細",
|
||||
"targetYear": 2030,
|
||||
"duration": 18.408,
|
||||
"duration": 18.192,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2,
|
||||
|
|
@ -1902,7 +1902,7 @@
|
|||
"text": "このグラフは、現在の収入と支出、和将来のライフイベントを考慮した上で、資産がどのように推移していくかを予測したものです。60歳で退職されると、一時的に資産が減少しますが、年金受給開始後は安定していく見込みです。",
|
||||
"insight": "資産推移",
|
||||
"targetYear": 2040,
|
||||
"duration": 19.728,
|
||||
"duration": 18.912,
|
||||
"type": "narration",
|
||||
"visualType": "chart",
|
||||
"visualAssetIndex": null,
|
||||
|
|
@ -1912,29 +1912,51 @@
|
|||
},
|
||||
{
|
||||
"id": 17,
|
||||
"text": "最後に、今後の対策とアドバイスです。",
|
||||
"insight": "対策とアドバイス",
|
||||
"text": "このライフプランレポートは、お客様の将来設計をサポートするために作成されましたが、現状の保険内容がライフプランに合っているか、家計に無理がないかを確認することが重要です。もし、保障内容が過剰であったり、逆に不足している場合は、保険の見直しを検討しましょう。また、家計の見直しを行い、無駄な支出を削減することも大切です。",
|
||||
"insight": "保険の見直しと家計の最適化は、安心できる将来設計の基盤となります。",
|
||||
"targetYear": 2024,
|
||||
"duration": 3.24,
|
||||
"duration": 28.968,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 5,
|
||||
"brollKeyword": "Japanese consultant pointing at document",
|
||||
"audioPath": "public\\segment_017.mp3",
|
||||
"brollPath": "broll/Consultant Pointing_7844849.mp4"
|
||||
"brollKeyword": "Japanese financial advisor explaining report",
|
||||
"audioPath": "public\\segment_017.mp3"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"text": "本日はありがとうございました。より詳細なライフプランや個別のご相談については、お気軽にお問い合わせください。",
|
||||
"insight": "クロージング",
|
||||
"text": "将来の資産形成のためには、NISAやiDeCoなどの制度を活用することも有効です。これらの制度は税制優遇があり、効率的に資産を形成することができます。ご自身のライフプランに合わせて、最適な制度を選択し、積極的に活用しましょう。",
|
||||
"insight": "NISAやiDeCoは、税制優遇を活用した効果的な資産形成手段です。",
|
||||
"targetYear": 2024,
|
||||
"duration": 9.6,
|
||||
"duration": 19.584,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 5,
|
||||
"brollKeyword": "Japanese financial advisor explaining report",
|
||||
"audioPath": "public\\segment_018.mp3"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"text": "将来に不安を感じることは誰にでもありますが、大切なのは、将来に向けて具体的な対策を講じることです。今回のライフプランレポートを参考に、ご自身の将来設計について改めて考えてみましょう。そして、もし何か不安なことや疑問点があれば、お気軽に担当者にご相談ください。専門家として、お客様の状況に合わせた最適なアドバイスをさせていただきます。",
|
||||
"insight": "将来への漠然とした不安を解消するには、具体的な行動が不可欠です。",
|
||||
"targetYear": 2024,
|
||||
"duration": 30.048,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 5,
|
||||
"brollKeyword": "Japanese financial advisor explaining report",
|
||||
"audioPath": "public\\segment_019.mp3"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"text": "本日はありがとうございました。テスト太郎様ご家族の幸せな未来を心より応援しております。より詳細なご相談は個別相談にて承ります。お待ちしております。",
|
||||
"insight": "本日はありがとうございました!",
|
||||
"targetYear": 2024,
|
||||
"duration": 13.368,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 6,
|
||||
"brollKeyword": "Japanese advisor bowing politely",
|
||||
"audioPath": "public\\segment_018.mp3",
|
||||
"brollPath": "broll/Polite Bowing_7958678.mp4"
|
||||
"brollKeyword": "Japanese business team smiling",
|
||||
"audioPath": "public\\segment_020.mp3"
|
||||
}
|
||||
],
|
||||
"assets": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -44,5 +44,20 @@
|
|||
"Japanese man introduced himself": "self-introduction",
|
||||
"Japanese financial consultant showing graphs": "showing graphs",
|
||||
"Japanese financial advisor consulting client": "client consulting",
|
||||
"Japanese man talking in interview": "interview talking"
|
||||
"Japanese man talking in interview": "interview talking",
|
||||
"日本人実業家オンライン投資": "Japanese businessman investing online",
|
||||
"アジア人女性経費追跡": "Asian woman tracking expenses",
|
||||
"アジア人女性保険見直し": "Asian woman reviewing insurance policy",
|
||||
"Japanese man worried about finances": "financial worries",
|
||||
"Japanese financial advisor discussing insurance": "insurance discussion",
|
||||
"Japanese businesswoman offering consultation": "business consultation",
|
||||
"Japanese business team smiling": "smiling business team",
|
||||
"Japanese family discussing finances": "family finances",
|
||||
"Asian businessman analyzing investment data": "investment analysis",
|
||||
"Japanese couple planning vacation": "vacation planning",
|
||||
"Japanese financial advisor explaining chart": "chart explanation",
|
||||
"Japanese parents talking about education": "education discussion",
|
||||
"Japanese senior couple enjoying retirement": "retirement enjoyment",
|
||||
"Japanese woman tracking expenses": "expense tracking",
|
||||
"Japanese businessman smiling": "smiling businessman"
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
{
|
||||
"transcription": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。 すいませんが、お客様のお名前からお願いいたします。 はい、私がテストテロとして、家族情報として、 生年月日の方は? はい、生年月日の方は、1980年の1月の1日です。 ということは、47歳ということでよろしいでしょうか。 はい。 現在のお勤め先はどこになりますでしょうか? IT系ですね。 IT系ですね。 はい。 すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。 年収が今700万くらいですね。 はい。 では、家族構成について、お客様、ご結婚はなされておりますか。 そうですね。 はい。 下の名前だけでいいので、奥様のお名前をお願いいたします。 花子です。 はい。奥様の生年月日を教えていただけますか。 1990年の1月の10日です。 ということは、現在37歳ということでよろしいでしょうか。 そうですね。 奥様は、ご勤めになられてますでしょうか。 そうですね。普通のサービス会ですけど、はい。 ざっくりでよろしいので、奥様の年収が600万くらいですね。 はい。 では続きまして、お子様の方はいらっしゃいますか。 はい、います。 何人いらっしゃいますか。 一人子だけです。 一人だけですね。 生年月日とかは。 2018年の3月の3日ですけど。 そうですか。 現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。 孤立です。 あ、孤立ですね。 ありがとうございます。",
|
||||
"duration": 141.82000732421875,
|
||||
"segments": [
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 13.0,
|
||||
"text": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。"
|
||||
},
|
||||
{
|
||||
"start": 13.0,
|
||||
"end": 18.0,
|
||||
"text": "すいませんが、お客様のお名前からお願いいたします。"
|
||||
},
|
||||
{
|
||||
"start": 18.0,
|
||||
"end": 29.0,
|
||||
"text": "はい、私がテストテロとして、家族情報として、"
|
||||
},
|
||||
{
|
||||
"start": 29.0,
|
||||
"end": 32.0,
|
||||
"text": "生年月日の方は?"
|
||||
},
|
||||
{
|
||||
"start": 32.0,
|
||||
"end": 41.0,
|
||||
"text": "はい、生年月日の方は、1980年の1月の1日です。"
|
||||
},
|
||||
{
|
||||
"start": 41.0,
|
||||
"end": 44.0,
|
||||
"text": "ということは、47歳ということでよろしいでしょうか。"
|
||||
},
|
||||
{
|
||||
"start": 44.0,
|
||||
"end": 45.0,
|
||||
"text": "はい。"
|
||||
},
|
||||
{
|
||||
"start": 45.0,
|
||||
"end": 48.0,
|
||||
"text": "現在のお勤め先はどこになりますでしょうか?"
|
||||
},
|
||||
{
|
||||
"start": 48.0,
|
||||
"end": 50.0,
|
||||
"text": "IT系ですね。"
|
||||
},
|
||||
{
|
||||
"start": 50.0,
|
||||
"end": 51.0,
|
||||
"text": "IT系ですね。"
|
||||
},
|
||||
{
|
||||
"start": 51.0,
|
||||
"end": 52.0,
|
||||
"text": "はい。"
|
||||
},
|
||||
{
|
||||
"start": 52.0,
|
||||
"end": 58.0,
|
||||
"text": "すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。"
|
||||
},
|
||||
{
|
||||
"start": 58.0,
|
||||
"end": 61.0,
|
||||
"text": "年収が今700万くらいですね。"
|
||||
},
|
||||
{
|
||||
"start": 61.0,
|
||||
"end": 62.0,
|
||||
"text": "はい。"
|
||||
},
|
||||
{
|
||||
"start": 62.0,
|
||||
"end": 69.0,
|
||||
"text": "では、家族構成について、お客様、ご結婚はなされておりますか。"
|
||||
},
|
||||
{
|
||||
"start": 69.0,
|
||||
"end": 70.0,
|
||||
"text": "そうですね。"
|
||||
},
|
||||
{
|
||||
"start": 70.0,
|
||||
"end": 71.0,
|
||||
"text": "はい。"
|
||||
},
|
||||
{
|
||||
"start": 71.0,
|
||||
"end": 75.0,
|
||||
"text": "下の名前だけでいいので、奥様のお名前をお願いいたします。"
|
||||
},
|
||||
{
|
||||
"start": 75.0,
|
||||
"end": 76.0,
|
||||
"text": "花子です。"
|
||||
},
|
||||
{
|
||||
"start": 76.0,
|
||||
"end": 80.0,
|
||||
"text": "はい。奥様の生年月日を教えていただけますか。"
|
||||
},
|
||||
{
|
||||
"start": 81.0,
|
||||
"end": 88.0,
|
||||
"text": "1990年の1月の10日です。"
|
||||
},
|
||||
{
|
||||
"start": 88.0,
|
||||
"end": 91.0,
|
||||
"text": "ということは、現在37歳ということでよろしいでしょうか。"
|
||||
},
|
||||
{
|
||||
"start": 91.0,
|
||||
"end": 92.0,
|
||||
"text": "そうですね。"
|
||||
},
|
||||
{
|
||||
"start": 92.0,
|
||||
"end": 96.0,
|
||||
"text": "奥様は、ご勤めになられてますでしょうか。"
|
||||
},
|
||||
{
|
||||
"start": 96.0,
|
||||
"end": 100.0,
|
||||
"text": "そうですね。普通のサービス会ですけど、はい。"
|
||||
},
|
||||
{
|
||||
"start": 100.0,
|
||||
"end": 105.0,
|
||||
"text": "ざっくりでよろしいので、奥様の年収が600万くらいですね。"
|
||||
},
|
||||
{
|
||||
"start": 105.0,
|
||||
"end": 106.0,
|
||||
"text": "はい。"
|
||||
},
|
||||
{
|
||||
"start": 106.0,
|
||||
"end": 110.0,
|
||||
"text": "では続きまして、お子様の方はいらっしゃいますか。"
|
||||
},
|
||||
{
|
||||
"start": 110.0,
|
||||
"end": 112.0,
|
||||
"text": "はい、います。"
|
||||
},
|
||||
{
|
||||
"start": 112.0,
|
||||
"end": 114.0,
|
||||
"text": "何人いらっしゃいますか。"
|
||||
},
|
||||
{
|
||||
"start": 114.0,
|
||||
"end": 115.0,
|
||||
"text": "一人子だけです。"
|
||||
},
|
||||
{
|
||||
"start": 115.0,
|
||||
"end": 116.0,
|
||||
"text": "一人だけですね。"
|
||||
},
|
||||
{
|
||||
"start": 116.0,
|
||||
"end": 118.0,
|
||||
"text": "生年月日とかは。"
|
||||
},
|
||||
{
|
||||
"start": 118.0,
|
||||
"end": 123.0,
|
||||
"text": "2018年の3月の3日ですけど。"
|
||||
},
|
||||
{
|
||||
"start": 123.0,
|
||||
"end": 124.0,
|
||||
"text": "そうですか。"
|
||||
},
|
||||
{
|
||||
"start": 124.0,
|
||||
"end": 135.0,
|
||||
"text": "現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。"
|
||||
},
|
||||
{
|
||||
"start": 135.0,
|
||||
"end": 136.0,
|
||||
"text": "孤立です。"
|
||||
},
|
||||
{
|
||||
"start": 136.0,
|
||||
"end": 137.0,
|
||||
"text": "あ、孤立ですね。"
|
||||
},
|
||||
{
|
||||
"start": 137.0,
|
||||
"end": 140.0,
|
||||
"text": "ありがとうございます。"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"家族"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Analyze PPT and PDF materials to extract main content structure
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pptx import Presentation
|
||||
import fitz # PyMuPDF
|
||||
|
||||
class MaterialAnalyzer:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
|
||||
def analyze_ppt(self):
|
||||
"""Analyze PPT structure and content"""
|
||||
print("=" * 80)
|
||||
print("📊 PPT分析结果")
|
||||
print("=" * 80)
|
||||
|
||||
ppt_path = self.project_root / "assets/0_input/ライフプラン情報.pptx"
|
||||
prs = Presentation(str(ppt_path))
|
||||
|
||||
print(f"\n总页数: {len(prs.slides)}页\n")
|
||||
|
||||
for idx, slide in enumerate(prs.slides):
|
||||
print(f"\n【第{idx + 1}页】")
|
||||
print("-" * 60)
|
||||
|
||||
# Extract title
|
||||
if slide.shapes.title:
|
||||
print(f"标题: {slide.shapes.title.text}")
|
||||
|
||||
# Extract text content
|
||||
text_content = []
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, "text") and shape.text.strip():
|
||||
if shape != slide.shapes.title:
|
||||
text_content.append(shape.text.strip())
|
||||
|
||||
if text_content:
|
||||
print(f"\n内容摘要:")
|
||||
for i, text in enumerate(text_content[:5], 1): # Show first 5 items
|
||||
preview = text[:100] + "..." if len(text) > 100 else text
|
||||
print(f" {i}. {preview}")
|
||||
|
||||
if len(text_content) > 5:
|
||||
print(f" ... (还有{len(text_content) - 5}项内容)")
|
||||
|
||||
print(f"\n文本框数量: {len(text_content)}")
|
||||
|
||||
def analyze_pdf(self):
|
||||
"""Analyze PDF structure and content"""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("📄 PDF分析结果")
|
||||
print("=" * 80)
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
doc = fitz.open(str(pdf_path))
|
||||
|
||||
print(f"\n总页数: {len(doc)}页\n")
|
||||
|
||||
for page_num in range(min(10, len(doc))): # Analyze first 10 pages
|
||||
page = doc[page_num]
|
||||
text = page.get_text()
|
||||
|
||||
print(f"\n【第{page_num + 1}页】")
|
||||
print("-" * 60)
|
||||
|
||||
# Extract key information
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
|
||||
if lines:
|
||||
print(f"页面内容预览:")
|
||||
for i, line in enumerate(lines[:15], 1): # Show first 15 lines
|
||||
preview = line[:80] + "..." if len(line) > 80 else line
|
||||
print(f" {i}. {preview}")
|
||||
|
||||
if len(lines) > 15:
|
||||
print(f" ... (还有{len(lines) - 15}行内容)")
|
||||
else:
|
||||
print(" (此页可能为图像或表格)")
|
||||
|
||||
print(f"\n文本行数: {len(lines)}")
|
||||
|
||||
if len(doc) > 10:
|
||||
print(f"\n... (还有{len(doc) - 10}页未显示)")
|
||||
|
||||
doc.close()
|
||||
|
||||
def find_family_info_pages(self):
|
||||
"""Find pages containing family information"""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("🔍 家庭信息页面定位")
|
||||
print("=" * 80)
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
doc = fitz.open(str(pdf_path))
|
||||
|
||||
keywords = ["テスト太郎", "家族", "年齢", "年収", "生年月日", "基本情報", "家族構成"]
|
||||
|
||||
print(f"\n搜索关键词: {', '.join(keywords)}\n")
|
||||
|
||||
matches = []
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
text = page.get_text()
|
||||
|
||||
found_keywords = [kw for kw in keywords if kw in text]
|
||||
if found_keywords:
|
||||
matches.append((page_num, found_keywords, text))
|
||||
|
||||
if matches:
|
||||
print(f"找到{len(matches)}个包含家庭信息的页面:\n")
|
||||
for page_num, keywords_found, text in matches[:5]: # Show first 5
|
||||
print(f"【PDF第{page_num + 1}页】(pdfPageIndex={page_num})")
|
||||
print(f" 匹配关键词: {', '.join(keywords_found)}")
|
||||
|
||||
# Show excerpt
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
print(f" 内容片段:")
|
||||
for line in lines[:8]:
|
||||
preview = line[:70] + "..." if len(line) > 70 else line
|
||||
print(f" {preview}")
|
||||
print()
|
||||
else:
|
||||
print("未找到包含家庭信息的页面")
|
||||
|
||||
doc.close()
|
||||
|
||||
def run(self):
|
||||
"""Execute full analysis"""
|
||||
try:
|
||||
self.analyze_ppt()
|
||||
self.analyze_pdf()
|
||||
self.find_family_info_pages()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ 分析完成")
|
||||
print("=" * 80)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 分析失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyzer = MaterialAnalyzer()
|
||||
analyzer.run()
|
||||
|
|
@ -38,7 +38,7 @@ class AudioGenerator:
|
|||
def generate_audio(
|
||||
self,
|
||||
script_segments: List[Dict[str, Any]],
|
||||
voice: str = "alloy",
|
||||
voice: str = "nova",
|
||||
model: str = "tts-1"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
|
@ -136,7 +136,7 @@ class AudioGenerator:
|
|||
|
||||
def generate_audio_for_script(
|
||||
script_data: Dict[str, Any],
|
||||
voice: str = "alloy"
|
||||
voice: str = "nova"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Convenience function to generate audio for a script
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
Dialogue Video Generator Module
|
||||
|
||||
This module extracts dialogue from video files, segments audio,
|
||||
and generates render props for dialogue-style videos with speech bubbles.
|
||||
|
||||
Usage:
|
||||
python backend/dialogue_video_generator.py --video path/to/video.mp4
|
||||
python backend/dialogue_video_generator.py # Auto-detect from assets/0_input/
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from backend.extractor import VideoExtractor
|
||||
|
||||
|
||||
class DialogueVideoGenerator:
|
||||
"""Generate dialogue video assets from interview recordings"""
|
||||
|
||||
def __init__(self, video_path: str = None):
|
||||
"""
|
||||
Initialize the generator
|
||||
|
||||
Args:
|
||||
video_path: Path to video file. If None, auto-detect from assets/0_input/
|
||||
"""
|
||||
load_dotenv()
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.video_path = self._resolve_video_path(video_path)
|
||||
self.segments_output = self.project_root / "assets/1_processed/video_segments.json"
|
||||
self.script_output = self.project_root / "assets/1_processed/dialogue_script.json"
|
||||
self.props_output = self.project_root / "assets/1_processed/render_props_dialogue.json"
|
||||
self.audio_output_dir = self.project_root / "public/dialogue"
|
||||
|
||||
def _resolve_video_path(self, video_path: str = None) -> Path:
|
||||
"""Resolve video path from input or auto-detect"""
|
||||
if video_path:
|
||||
return Path(video_path)
|
||||
|
||||
# Auto-detect from assets/0_input/
|
||||
input_dir = self.project_root / "assets/0_input"
|
||||
video_extensions = ["*.mp4", "*.mov", "*.avi", "*.mkv"]
|
||||
|
||||
for ext in video_extensions:
|
||||
video_files = list(input_dir.glob(ext))
|
||||
if video_files:
|
||||
print(f"✓ Auto-detected video: {video_files[0].name}")
|
||||
return video_files[0]
|
||||
|
||||
raise FileNotFoundError("No video file found. Please provide --video path or place video in assets/0_input/")
|
||||
|
||||
def extract_segments(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract audio transcription and timestamps from video
|
||||
|
||||
Returns:
|
||||
Dictionary with transcription, segments, and metadata
|
||||
"""
|
||||
print(f"📹 Extracting segments from: {self.video_path.name}")
|
||||
|
||||
if not self.video_path.exists():
|
||||
raise FileNotFoundError(f"Video file not found: {self.video_path}")
|
||||
|
||||
extractor = VideoExtractor()
|
||||
result = extractor.extract(str(self.video_path))
|
||||
|
||||
# Save segments
|
||||
with open(self.segments_output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Saved {len(result['segments'])} segments to {self.segments_output.name}")
|
||||
return result
|
||||
|
||||
def slice_audio(self, input_path: str, start: float, end: float, output_path: str):
|
||||
"""Slice audio segment using ffmpeg"""
|
||||
duration = end - start
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-t", str(duration),
|
||||
"-i", str(input_path),
|
||||
"-c:a", "libmp3lame", "-b:a", "128k",
|
||||
str(output_path)
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
def identify_speaker(self, text: str, last_speaker: str) -> str:
|
||||
"""
|
||||
Identify speaker based on text content
|
||||
|
||||
Args:
|
||||
text: Dialogue text
|
||||
last_speaker: Previous segment's speaker (for alternation)
|
||||
|
||||
Returns:
|
||||
Speaker label: "FP" or "家族代表様"
|
||||
"""
|
||||
# FP (Financial Planner) keywords
|
||||
fp_keywords = [
|
||||
"?", "お願いいたします", "でしょうか",
|
||||
"教えていただけますか", "ありがとうございます",
|
||||
"いらっしゃいますか", "そうですか"
|
||||
]
|
||||
|
||||
is_fp = any(kw in text for kw in fp_keywords)
|
||||
|
||||
# Short confirmations alternate between speakers
|
||||
if text in ["はい。", "そうですね。"]:
|
||||
return "FP" if last_speaker == "家族代表様" else "家族代表様"
|
||||
|
||||
return "FP" if is_fp else "家族代表様"
|
||||
|
||||
def generate_audio_clips(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Generate individual audio clips for each dialogue segment
|
||||
|
||||
Args:
|
||||
segments: List of segment dictionaries with text and timestamps
|
||||
|
||||
Returns:
|
||||
List of processed segments with speaker labels and audio paths
|
||||
"""
|
||||
print(f"🎙️ Generating {len(segments)} audio clips...")
|
||||
|
||||
# Create output directory
|
||||
self.audio_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find audio file
|
||||
audio_path = self.video_path.parent / f"{self.video_path.stem}_audio.wav"
|
||||
if not audio_path.exists():
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
processed_segments = []
|
||||
last_speaker = "家族代表様"
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
speaker = self.identify_speaker(text, last_speaker)
|
||||
last_speaker = speaker
|
||||
|
||||
# Generate audio clip
|
||||
segment_filename = f"dialogue_seg_{i:03d}.mp3"
|
||||
segment_path = self.audio_output_dir / segment_filename
|
||||
|
||||
print(f" [{i+1}/{len(segments)}] {text[:30]}...")
|
||||
self.slice_audio(str(audio_path), seg["start"], seg["end"], str(segment_path))
|
||||
|
||||
processed_segments.append({
|
||||
"id": i + 1,
|
||||
"text": text,
|
||||
"duration": seg["end"] - seg["start"],
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": speaker,
|
||||
"audioPath": f"public/dialogue/{segment_filename}",
|
||||
"brollKeyword": "Japanese interview"
|
||||
})
|
||||
|
||||
# Save dialogue script
|
||||
with open(self.script_output, "w", encoding="utf-8") as f:
|
||||
json.dump(processed_segments, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Generated {len(processed_segments)} dialogue segments")
|
||||
return processed_segments
|
||||
|
||||
def create_render_props(self, dialogue_script: List[Dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create render_props.json for dialogue video
|
||||
|
||||
Args:
|
||||
dialogue_script: List of dialogue segments
|
||||
|
||||
Returns:
|
||||
Complete render props dictionary
|
||||
"""
|
||||
print("📝 Creating render props for dialogue video...")
|
||||
|
||||
# Load original render_props for meta and charts
|
||||
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
|
||||
if original_props_path.exists():
|
||||
with open(original_props_path, "r", encoding="utf-8") as f:
|
||||
original_props = json.load(f)
|
||||
else:
|
||||
# Minimal fallback structure
|
||||
original_props = {
|
||||
"meta": {"title": "対話動画", "currencyUnit": "万円", "duration": 180},
|
||||
"charts": {},
|
||||
"assets": {"broll": {}, "videoClips": [], "slides": []}
|
||||
}
|
||||
|
||||
# Construct new props
|
||||
new_props = {
|
||||
"meta": original_props.get("meta", {}),
|
||||
"charts": original_props.get("charts", {}),
|
||||
"script": dialogue_script,
|
||||
"assets": original_props.get("assets", {})
|
||||
}
|
||||
|
||||
# Update title
|
||||
if "title" in new_props["meta"]:
|
||||
new_props["meta"]["title"] = "【対話版】" + new_props["meta"]["title"]
|
||||
|
||||
# Save render props
|
||||
with open(self.props_output, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Saved render props to {self.props_output.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""
|
||||
Execute full dialogue video generation pipeline
|
||||
|
||||
Returns:
|
||||
Path to generated render_props.json
|
||||
"""
|
||||
print("🚀 Starting Dialogue Video Generation Pipeline...")
|
||||
print()
|
||||
|
||||
# Step 1: Extract segments
|
||||
result = self.extract_segments()
|
||||
segments = result["segments"]
|
||||
print()
|
||||
|
||||
# Step 2: Generate audio clips
|
||||
processed_segments = self.generate_audio_clips(segments)
|
||||
print()
|
||||
|
||||
# Step 3: Create render props
|
||||
self.create_render_props(processed_segments)
|
||||
print()
|
||||
|
||||
print(f"✅ Pipeline complete! Use the following command to render:")
|
||||
print(f" npx remotion render MainVideo assets/2_output/dialogue_video.mp4 \\")
|
||||
print(f" --props=\"{self.props_output.relative_to(self.project_root)}\"")
|
||||
|
||||
return str(self.props_output)
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dialogue Video Generator - Extract and render interview dialogues",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python backend/dialogue_video_generator.py
|
||||
python backend/dialogue_video_generator.py --video assets/0_input/interview.mp4
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--video",
|
||||
type=str,
|
||||
help="Path to video file (auto-detect from assets/0_input/ if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
generator = DialogueVideoGenerator(video_path=args.video)
|
||||
generator.run()
|
||||
except Exception as e:
|
||||
print(f"\n❌ Generation failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
|
|
@ -41,7 +41,7 @@ class PDFExtractor:
|
|||
raise ImportError("google-genai not installed. Run: pip install google-genai")
|
||||
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
self.model = 'gemini-2.0-flash-exp'
|
||||
self.model = 'gemini-2.0-flash'
|
||||
|
||||
def extract(self, pdf_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -109,6 +109,9 @@ class PDFExtractor:
|
|||
"target_amount": 目標金額(万円),
|
||||
"target_year": 達成目標年
|
||||
}
|
||||
],
|
||||
"advice": [
|
||||
"PDFの最後の方にある『対策とアドバイス』や『FPのコメント』から具体的な助言を箇条書きで抽出してください"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
"""
|
||||
Generate comprehensive video using PDF pages as visual anchor (NOT PPT)
|
||||
|
||||
CRITICAL: This script uses PDF page content as the structural backbone
|
||||
- PDF pages are converted to images and used as visual materials
|
||||
- PPT slides are NOT used as the main visual sequence
|
||||
- Content organization follows PDF page structure
|
||||
|
||||
Usage:
|
||||
python backend/generate_combined_video.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
|
||||
class CombinedVideoGenerator:
|
||||
"""Generate video with PDF pages as structural anchor"""
|
||||
|
||||
def __init__(self):
|
||||
load_dotenv()
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.api_key = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("GEMINI_API_KEY not found in .env")
|
||||
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
|
||||
def convert_pdf_to_images(self) -> List[str]:
|
||||
"""Convert PDF pages to images for video use"""
|
||||
print("📄 Converting PDF pages to images...")
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
print("⚠️ PyMuPDF not installed, using placeholder images")
|
||||
return []
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
output_dir = self.project_root / "public" / "pdf_pages"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc = fitz.open(str(pdf_path))
|
||||
image_paths = []
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x resolution
|
||||
|
||||
image_filename = f"pdf_page_{page_num + 1:03d}.png"
|
||||
image_path = output_dir / image_filename
|
||||
pix.save(str(image_path))
|
||||
|
||||
image_paths.append(f"pdf_pages/{image_filename}")
|
||||
print(f" ✓ Page {page_num + 1}/{len(doc)}")
|
||||
|
||||
doc.close()
|
||||
print(f"✓ Converted {len(image_paths)} PDF pages")
|
||||
return image_paths
|
||||
|
||||
def load_all_materials(self) -> Dict[str, Any]:
|
||||
"""Load materials with PDF as primary visual source"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
|
||||
print("📦 Loading all materials...")
|
||||
|
||||
# Convert PDF to images (PRIMARY VISUAL SOURCE)
|
||||
pdf_images = self.convert_pdf_to_images()
|
||||
|
||||
# Load existing render_props for charts ONLY
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, "r", encoding="utf-8") as f:
|
||||
props_data = json.load(f)
|
||||
print(f"✓ Loaded chart data")
|
||||
|
||||
# Prepare PDF summary
|
||||
pdf_summary = {
|
||||
"client_name": props_data["meta"].get("clientName", "お客様"),
|
||||
"current_age": 47,
|
||||
"spouse_age": 37,
|
||||
"child_age": 7,
|
||||
"annual_income": 703,
|
||||
"current_assets": 836,
|
||||
"annual_expenditure": 1038,
|
||||
"charts": props_data["charts"],
|
||||
"pdf_images": pdf_images # PRIMARY VISUAL ASSETS
|
||||
}
|
||||
print(f"✓ Prepared PDF data with {len(pdf_images)} page images")
|
||||
|
||||
return {
|
||||
"pdf": pdf_summary,
|
||||
"existing_props": props_data
|
||||
}
|
||||
|
||||
def generate_comprehensive_script(self, materials: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate script using PDF page structure (NOT PPT)
|
||||
|
||||
Structure based on PDF pages:
|
||||
1. Opening (intro)
|
||||
2. Family basic info (PDF page 1)
|
||||
3. Life events (PDF page 2)
|
||||
4. Asset projection intro (PDF page 3)
|
||||
5. Chart analysis (use chart view)
|
||||
6. Education plan (PDF page 4)
|
||||
7. Summary (PDF page 5)
|
||||
"""
|
||||
print("🤖 Generating script based on PDF structure...")
|
||||
|
||||
pdf_data = materials["pdf"]
|
||||
pdf_images = pdf_data["pdf_images"]
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、動画解説のナレーターです。
|
||||
|
||||
以下の情報に基づいて、**PDFページを視覚素材とする**解説動画のナレーション原稿を作成してください。
|
||||
|
||||
【重要】視覚素材はPDFページ画像を使用します(PPTスライドではありません)
|
||||
|
||||
【家族情報】
|
||||
- 家族代表: {pdf_data["client_name"]} ({pdf_data["current_age"]}歳)
|
||||
- 配偶者: {pdf_data["spouse_age"]}歳
|
||||
- お子様: {pdf_data["child_age"]}歳
|
||||
- 現在の年収: {pdf_data["annual_income"]}万円
|
||||
- 現在の資産: {pdf_data["current_assets"]}万円
|
||||
- 年間支出: {pdf_data["annual_expenditure"]}万円
|
||||
|
||||
【利用可能なPDFページ画像】
|
||||
{len(pdf_images)}ページ分の画像が利用可能
|
||||
|
||||
【要件】
|
||||
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||||
- intro: オープニング(10秒、家族紹介の導入)
|
||||
- rep_intro: 家族代表紹介(20秒、PDFページ1、代表者の詳細)
|
||||
- spouse_intro: 配偶者紹介(15秒、PDFページ1、配偶者の詳細)
|
||||
- child_intro: お子様紹介(15秒、PDFページ1、お子様と教育プラン)
|
||||
- family_summary: 家族紹介まとめ(10秒、家族全体のまとめ)
|
||||
|
||||
2. 各セグメントの構造:
|
||||
- id: セグメントID
|
||||
- text: ナレーション文(具体的な数値・年齢・職業を含む)
|
||||
- duration: 推定時間(秒)
|
||||
- visualType: "pdf_page"
|
||||
- pdfPageIndex: PDFページ番号(0始まり)
|
||||
- highlightArea: "full"
|
||||
|
||||
3. **重要**: 家族成員の年齢、職業、年収など具体的な情報を丁寧に紹介
|
||||
4. 温かみのある親しみやすい語り口で、家族の魅力を伝える
|
||||
|
||||
**出力形式**: JSON配列
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.7,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
result = json.loads(response.text)
|
||||
print(f"✓ Generated {len(result)} script segments")
|
||||
return result
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio for each segment"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
print("🎙️ Generating audio...")
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = self.project_root / "public" / "pdf_narration"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i+1}/{len(segments)}] {text[:40]}...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/pdf_narration/{audio_filename}"
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
continue
|
||||
|
||||
print(f"✓ Generated {len(segments)} audio files")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||
"""Create render_props using PDF pages as visuals"""
|
||||
|
||||
existing_props = materials["existing_props"]
|
||||
pdf_images = materials["pdf"]["pdf_images"]
|
||||
|
||||
# Build PDF page assets
|
||||
pdf_page_assets = [
|
||||
{
|
||||
"index": i,
|
||||
"image_path": f"public/{img}",
|
||||
"description": f"PDF Page {i+1}"
|
||||
}
|
||||
for i, img in enumerate(pdf_images)
|
||||
]
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": f"【PDF解説】{materials['pdf']['client_name']}様のライフプラン",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": materials["pdf"]["client_name"]
|
||||
},
|
||||
"charts": existing_props["charts"],
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"pdfPages": pdf_page_assets, # PDF pages as PRIMARY visuals
|
||||
"slides": [], # Empty - not using PPT
|
||||
"videoClips": existing_props["assets"].get("videoClips", []),
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_pdf_based.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Saved PDF-based render props to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print("🚀 Generating PDF-based comprehensive video...\n")
|
||||
|
||||
# Load materials (PDF as primary)
|
||||
materials = self.load_all_materials()
|
||||
print()
|
||||
|
||||
# Generate script
|
||||
segments = self.generate_comprehensive_script(materials)
|
||||
print()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
print()
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments, materials)
|
||||
print()
|
||||
|
||||
output_file = "assets/2_output/pdf_based_video.mp4"
|
||||
print(f"✅ Pipeline complete! Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_pdf_based.json\"")
|
||||
print()
|
||||
print("📋 改动说明:")
|
||||
print(" 1. 替换视觉素材: PPT slides → PDF page images")
|
||||
print(" 2. 结构锚点: 按PDF页面组织内容,而非PPT顺序")
|
||||
print(" 3. 新增assets.pdfPages字段存储PDF页面图像")
|
||||
print(" 4. visualType: 'pdf_page'(替代'ppt')")
|
||||
print(" 5. pdfPageIndex: 指向PDF页码(替代slideIndex)")
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
generator = CombinedVideoGenerator()
|
||||
generator.run()
|
||||
except Exception as e:
|
||||
print(f"\n❌ Generation failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
"""
|
||||
Generate detailed narration video for specific PPT slide with PDF context
|
||||
|
||||
This script creates a comprehensive narration video combining:
|
||||
1. PPT slide content (text + visual)
|
||||
2. PDF financial data context
|
||||
3. AI-generated detailed explanation
|
||||
4. Smooth transitions between content sections
|
||||
|
||||
Usage:
|
||||
python backend/generate_detailed_narration.py --slide 2
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Import Gemini API
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
|
||||
class DetailedNarrationGenerator:
|
||||
"""Generate detailed narration video for specific slide"""
|
||||
|
||||
def __init__(self, slide_index: int = 2):
|
||||
load_dotenv()
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.slide_index = slide_index
|
||||
self.api_key = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("GEMINI_API_KEY not found in .env")
|
||||
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
|
||||
def load_ppt_slide(self) -> Dict[str, Any]:
|
||||
"""Load specific PPT slide data"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.ppt_extractor import extract_ppt_data
|
||||
|
||||
ppt_path = self.project_root / "assets/0_input/ライフプラン情報.pptx"
|
||||
ppt_data = extract_ppt_data(str(ppt_path))
|
||||
|
||||
if self.slide_index >= len(ppt_data["slides"]):
|
||||
raise ValueError(f"Slide {self.slide_index} not found")
|
||||
|
||||
return ppt_data["slides"][self.slide_index]
|
||||
|
||||
def load_pdf_context(self) -> Dict[str, Any]:
|
||||
"""Load PDF financial data as context"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.extractor import extract_pdf_data
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
return extract_pdf_data(str(pdf_path))
|
||||
|
||||
def generate_narration_script(self, slide: Dict, pdf_data: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate detailed narration script using Gemini
|
||||
|
||||
Returns:
|
||||
List of narration segments with text, duration, visual cues
|
||||
"""
|
||||
print(f"🤖 Generating detailed narration for slide {self.slide_index}...")
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||||
|
||||
以下の情報に基づいて、詳細な解説動画用のナレーション原稿を作成してください。
|
||||
|
||||
【PPTスライド内容】
|
||||
タイトル: {slide['title']}
|
||||
テキスト:
|
||||
{slide['text']}
|
||||
|
||||
【PDFから抽出した追加情報】
|
||||
- 家族代表: {pdf_data.get('client_name', 'テスト太郎')} ({pdf_data.get('current_age', 47)}歳)
|
||||
- 年収: {pdf_data.get('annual_income', 703)}万円
|
||||
- 現在の資産: {pdf_data.get('current_assets', 836)}万円
|
||||
- 年間支出: {pdf_data.get('annual_expenditure', 1038)}万円
|
||||
|
||||
【要件】
|
||||
1. スライドの内容を**段落ごとに分解**し、各段落を詳細に解説してください
|
||||
2. 各セグメントは以下の構造で出力してください:
|
||||
- intro: スライド全体の導入(15秒程度)
|
||||
- section_1: 家族代表の基本情報(20秒程度)
|
||||
- section_2: 配偶者情報(15秒程度)
|
||||
- section_3: お子様情報と教育プラン(20秒程度)
|
||||
- summary: まとめ(10秒程度)
|
||||
|
||||
3. 各セグメントには以下を含めてください:
|
||||
- text: ナレーション文(自然な口語体、具体的な数値を含む)
|
||||
- duration: 推定時間(秒)
|
||||
- visualCue: 画面表示の指示("ppt" / "zoom_in" / "highlight_section")
|
||||
- highlightArea: 強調する領域("top" / "middle" / "bottom" / "full")
|
||||
|
||||
4. 数値やデータは必ず具体的に言及し、抽象的な表現は避けてください
|
||||
5. 親しみやすく、わかりやすい言葉遣いを心がけてください
|
||||
|
||||
**出力形式**: JSON配列として返してください
|
||||
[
|
||||
{{
|
||||
"id": "intro",
|
||||
"text": "それでは、ご家族の基本情報について詳しく見ていきましょう。...",
|
||||
"duration": 15,
|
||||
"visualCue": "ppt",
|
||||
"highlightArea": "full"
|
||||
}},
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.7,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
result = json.loads(response.text)
|
||||
print(f"✓ Generated {len(result)} narration segments")
|
||||
return result
|
||||
|
||||
def create_smooth_transitions(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Add transition effects between segments"""
|
||||
enhanced_segments = []
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
# Enhance segment with visual metadata
|
||||
enhanced_seg = {
|
||||
**seg,
|
||||
"type": "narration",
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": self.slide_index,
|
||||
"brollKeyword": f"Japanese family planning {seg.get('highlightArea', 'full')}",
|
||||
}
|
||||
|
||||
enhanced_segments.append(enhanced_seg)
|
||||
|
||||
return enhanced_segments
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio for each segment"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
print("🎙️ Generating audio for narration segments...")
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = self.project_root / "public" / f"slide_{self.slide_index}_narration"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i+1}] Generating: {text[:50]}...")
|
||||
|
||||
# Use OpenAI TTS API directly
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
# Get actual duration
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
|
||||
seg["audioPath"] = f"public/slide_{self.slide_index}_narration/{audio_filename}"
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
continue
|
||||
|
||||
print(f"✓ Generated audio for {len(segments)} segments")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict], slide: Dict, pdf_data: Dict) -> Dict:
|
||||
"""Create render_props.json for the detailed narration video"""
|
||||
|
||||
# Load original props for charts
|
||||
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
if original_props_path.exists():
|
||||
with open(original_props_path, "r", encoding="utf-8") as f:
|
||||
original_props = json.load(f)
|
||||
else:
|
||||
original_props = {"charts": {}, "assets": {"slides": []}}
|
||||
|
||||
# Construct new props
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": f"【詳細解説】{slide['title']} - {pdf_data.get('client_name', 'お客様')}様",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": pdf_data.get("client_name", "お客様")
|
||||
},
|
||||
"charts": original_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
**original_props.get("assets", {}),
|
||||
"targetSlide": slide
|
||||
}
|
||||
}
|
||||
|
||||
# Save to file
|
||||
output_path = self.project_root / f"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Saved render props to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print(f"🚀 Generating detailed narration video for slide {self.slide_index}...")
|
||||
print()
|
||||
|
||||
# Load data
|
||||
slide = self.load_ppt_slide()
|
||||
pdf_data = self.load_pdf_context()
|
||||
print(f"✓ Loaded slide: {slide['title']}")
|
||||
print(f"✓ Loaded PDF context for: {pdf_data.get('client_name')}")
|
||||
print()
|
||||
|
||||
# Generate narration
|
||||
segments = self.generate_narration_script(slide, pdf_data)
|
||||
print()
|
||||
|
||||
# Add transitions
|
||||
segments = self.create_smooth_transitions(segments)
|
||||
print(f"✓ Added smooth transitions")
|
||||
print()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
print()
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments, slide, pdf_data)
|
||||
print()
|
||||
|
||||
output_file = f"assets/2_output/slide_{self.slide_index}_detailed.mp4"
|
||||
print(f"✅ Pipeline complete! Use the following command to render:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json\"")
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate detailed narration video for specific PPT slide"
|
||||
)
|
||||
parser.add_argument("--slide", type=int, default=2, help="Slide index (default: 2)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
generator = DetailedNarrationGenerator(slide_index=args.slide)
|
||||
generator.run()
|
||||
except Exception as e:
|
||||
print(f"\n❌ Generation failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Generate family introduction video combining PPT and PDF pages
|
||||
Focus: Family member introduction ONLY
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from dotenv import load_dotenv
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
# Load environment
|
||||
load_dotenv()
|
||||
|
||||
class FamilyIntroGenerator:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
|
||||
# Initialize Gemini client
|
||||
api_key = os.getenv("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||
self.client = genai.Client(api_key=api_key)
|
||||
|
||||
def load_materials(self) -> Dict:
|
||||
"""Load PPT, PDF and existing render_props data"""
|
||||
print("📦 Loading materials...")
|
||||
|
||||
# Load existing render_props
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
props_data = json.load(f)
|
||||
|
||||
# Convert PDF pages to images
|
||||
pdf_images = self.convert_pdf_to_images()
|
||||
|
||||
# Extract family info from existing data
|
||||
family_info = {
|
||||
"rep_name": props_data["meta"].get("clientName", "テスト太郎"),
|
||||
"rep_age": 47,
|
||||
"rep_birth": "1980年1月1日",
|
||||
"rep_income": 703,
|
||||
"rep_industry": "情報通信業",
|
||||
"spouse_name": "花子",
|
||||
"spouse_age": 37,
|
||||
"spouse_birth": "1990年2月2日",
|
||||
"spouse_income": 368,
|
||||
"spouse_industry": "卸売・小売業",
|
||||
"child_name": "息子",
|
||||
"child_age": 7,
|
||||
"child_birth": "2020年3月3日",
|
||||
"child_education": "保育園認可→小学校公立→中学校私立",
|
||||
"total_income": 1071,
|
||||
"total_assets": 852,
|
||||
"annual_expense": 1038
|
||||
}
|
||||
|
||||
print(f"✓ Loaded PPT slides: {len(props_data['assets']['slides'])} pages")
|
||||
print(f"✓ Loaded PDF images: {len(pdf_images)} pages")
|
||||
|
||||
return {
|
||||
"family": family_info,
|
||||
"ppt_slides": props_data["assets"]["slides"],
|
||||
"pdf_images": pdf_images,
|
||||
"broll": props_data["assets"].get("broll", {}),
|
||||
"video_clips": props_data["assets"].get("videoClips", []),
|
||||
"existing_props": props_data
|
||||
}
|
||||
|
||||
def convert_pdf_to_images(self) -> List[str]:
|
||||
"""Convert PDF pages to images"""
|
||||
import fitz # PyMuPDF
|
||||
|
||||
print("📄 Converting PDF pages to images...")
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
output_dir = self.project_root / "public" / "pdf_pages"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc = fitz.open(str(pdf_path))
|
||||
image_paths = []
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||
|
||||
image_filename = f"pdf_page_{page_num + 1:03d}.png"
|
||||
image_path = output_dir / image_filename
|
||||
pix.save(str(image_path))
|
||||
image_paths.append(f"pdf_pages/{image_filename}")
|
||||
|
||||
if (page_num + 1) % 10 == 0:
|
||||
print(f" ✓ Converted {page_num + 1}/{len(doc)} pages")
|
||||
|
||||
print(f"✓ Converted {len(doc)} PDF pages")
|
||||
doc.close()
|
||||
return image_paths
|
||||
|
||||
def generate_script(self, materials: Dict) -> List[Dict]:
|
||||
"""Generate family introduction script with PPT and PDF switching"""
|
||||
print("🤖 Generating family introduction script...")
|
||||
|
||||
family = materials["family"]
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||||
|
||||
【重要】家族成員紹介のみに焦点を当てた動画原稿を作成してください。
|
||||
PPTページとPDFページを適切に切り替えながら、家族の魅力を伝えます。
|
||||
|
||||
【家族情報】
|
||||
- 家族代表: {family["rep_name"]} ({family["rep_age"]}歳、生年月日{family["rep_birth"]})
|
||||
業種: {family["rep_industry"]}
|
||||
年収: {family["rep_income"]}万円
|
||||
- 配偶者: {family["spouse_name"]} ({family["spouse_age"]}歳、生年月日{family["spouse_birth"]})
|
||||
業種: {family["spouse_industry"]}
|
||||
年収: {family["spouse_income"]}万円
|
||||
- お子様: {family["child_name"]} ({family["child_age"]}歳、生年月日{family["child_birth"]})
|
||||
教育: {family["child_education"]}
|
||||
- 世帯年収: {family["total_income"]}万円
|
||||
- 現在資産: {family["total_assets"]}万円
|
||||
- 年間支出: {family["annual_expense"]}万円
|
||||
|
||||
【利用可能な視覚素材】
|
||||
- PPTスライド: {len(materials["ppt_slides"])}ページ
|
||||
スライド0=タイトルページ「ライフプラン」
|
||||
スライド1=家族基本情報ページ(テスト太郎47歳/花子37歳/息子7歳、業種・年収・進学プラン詳細)
|
||||
- PDFページ: {len(materials["pdf_images"])}ページ
|
||||
ページ0=表紙
|
||||
ページ1=目次
|
||||
ページ2=家族基本情報詳細(テスト太郎/花子/息子の生年月日・年齢・年収・業種)
|
||||
ページ3=ライフイベント一覧
|
||||
|
||||
【要件】
|
||||
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||||
- intro: オープニング(10秒、PPTスライド0でタイトル表示)
|
||||
- family_overview: 家族全体の概要(20秒、PPTスライド1、3人家族構成・業種・年収紹介)
|
||||
- family_details: 家族詳細情報(30秒、PDFページ2のまま継続、代表者→配偶者→子供の詳細)
|
||||
- financial_summary: 家計まとめ(10秒、PPTスライド1で締めくくり)
|
||||
|
||||
2. 各セグメントの構造:
|
||||
- id: セグメントID(英数字)
|
||||
- text: ナレーション文(温かみのある語り口、具体的な数値・業種含む)
|
||||
- duration: 推定時間(秒)
|
||||
- visualType: "ppt" or "pdf_page"
|
||||
- visualAssetIndex: PPTスライド番号(visualType="ppt"の場合、0始まり)
|
||||
- pdfPageIndex: PDFページ番号(visualType="pdf_page"の場合、0始まり)
|
||||
- highlightArea: "full"
|
||||
|
||||
3. **重要ポイント**:
|
||||
- PPT素材を最大活用:スライド1には家族全員の詳細情報が記載されている
|
||||
- 同じページは連続して使用し、途中で別ページに切り替えない
|
||||
- PPTスライド1で家族概要を紹介後、PDFページ2で各成員の詳細を深掘り
|
||||
- 各家族成員の個性や魅力を丁寧に紹介(年齢・生年月日・業種・年収すべて言及)
|
||||
|
||||
**出力形式**: JSON配列のみ
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.7,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
result = json.loads(response.text)
|
||||
print(f"✓ Generated {len(result)} script segments")
|
||||
return result
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio for each segment"""
|
||||
import sys
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
print("🎙️ Generating audio...")
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = self.project_root / "public" / "family_intro_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i+1}/{len(segments)}] {seg['id']}: {text[:30]}...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/family_intro_audio/{audio_filename}"
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
continue
|
||||
|
||||
print(f"✓ Generated {len(segments)} audio files")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||
"""Create render_props with PPT and PDF pages"""
|
||||
|
||||
existing_props = materials["existing_props"]
|
||||
pdf_images = materials["pdf_images"]
|
||||
|
||||
# Build PDF page assets
|
||||
pdf_page_assets = [
|
||||
{
|
||||
"index": i,
|
||||
"image_path": f"public/{img}",
|
||||
"description": f"PDF Page {i+1}"
|
||||
}
|
||||
for i, img in enumerate(pdf_images)
|
||||
]
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": f"【家族紹介】{materials['family']['rep_name']}様ご家族",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": materials["family"]["rep_name"]
|
||||
},
|
||||
"charts": existing_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"pdfPages": pdf_page_assets,
|
||||
"slides": existing_props["assets"]["slides"], # Keep PPT slides
|
||||
"videoClips": existing_props["assets"].get("videoClips", []),
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_family_intro.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ Saved family intro render props to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print("🚀 Generating family introduction video...\n")
|
||||
|
||||
# Load materials
|
||||
materials = self.load_materials()
|
||||
print()
|
||||
|
||||
# Generate script
|
||||
segments = self.generate_script(materials)
|
||||
print()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
print()
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments, materials)
|
||||
print()
|
||||
|
||||
output_file = "assets/2_output/family_intro_video.mp4"
|
||||
print(f"✅ Pipeline complete! Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_family_intro.json\" --concurrency=1")
|
||||
print()
|
||||
print("📋 改动说明:")
|
||||
print(" 1. 内容范围: 仅家族成员介绍(代表者+配偶者+お子様)")
|
||||
print(" 2. 视觉素材: PPT与PDF页面自然切换")
|
||||
print(" 3. 无主干依赖: 两种素材平衡使用,不依赖单一文件作为主线")
|
||||
print(" 4. 语调: 温暖亲切的家庭介绍风格")
|
||||
print(f" 5. 总时长: {props['meta']['duration']:.1f}秒")
|
||||
|
||||
return output_file
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = FamilyIntroGenerator()
|
||||
generator.run()
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Generate introduction video for PPT slides 2 & 3
|
||||
Final version - manually crafted detailed narration based on PDF/PPT content
|
||||
Only requires OpenAI TTS API (no Gemini needed)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class PPTIntroFinalGenerator:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
print("✓ Generator initialized")
|
||||
|
||||
def create_detailed_script(self) -> List[Dict]:
|
||||
"""
|
||||
Create detailed narration script based on PDF/PPT analysis
|
||||
Manually crafted for maximum quality and relevance
|
||||
"""
|
||||
|
||||
print("\n📝 Creating detailed narration script...")
|
||||
|
||||
# Detailed script based on actual PDF/PPT content
|
||||
# These are high-quality narrations that cover all important information
|
||||
segments = [
|
||||
{
|
||||
"id": "s2_welcome",
|
||||
"text": "皆様、こんにちは。本日は、テスト太郎様とそのご家族のライフプランについてご紹介させていただきます。まずは、ご家族の基本情報からご覧ください。",
|
||||
"estimatedDuration": 12,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1, # slide_002.png
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s2_representative",
|
||||
"text": "ご家族の代表者、テスト太郎様は現在47歳。1980年1月1日生まれで、情報通信業界でご活躍されています。年収は703万円と、安定した収入を得ていらっしゃいます。業界の専門知識を活かし、キャリアを積み重ねてこられました。",
|
||||
"estimatedDuration": 28,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s2_spouse",
|
||||
"text": "続いて、奥様の花子様は37歳。1990年2月2日生まれで、卸売・小売業でお仕事をされており、年収は368万円です。ご家族を支えながら、ご自身のキャリアも大切にされている、素敵な方です。",
|
||||
"estimatedDuration": 23,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s2_child",
|
||||
"text": "お子様は現在7歳。2020年3月3日生まれで、すくすくと成長されています。教育プランとしては、保育園の認可園からスタートし、公立小学校へ進学。その後、私立中学校での教育を予定されています。お子様の可能性を最大限に伸ばす、しっかりとした教育方針をお持ちです。",
|
||||
"estimatedDuration": 32,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s2_household",
|
||||
"text": "テスト様ご家族の世帯年収は、合計で1071万円。現在の資産は852万円をお持ちです。年間の支出は1038万円となっており、計画的な家計管理をされています。ご夫婦で力を合わせ、安定した家計を築いていらっしゃいますね。",
|
||||
"estimatedDuration": 28,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s3_transition",
|
||||
"text": "それでは次に、ご家族の将来に予定されているライフイベントについて見ていきましょう。こちらのスライドをご覧ください。",
|
||||
"estimatedDuration": 11,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2, # slide_003.png
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s3_life_events",
|
||||
"text": "今後、さまざまなライフイベントが控えています。お子様の進学に伴う教育費、マイホームの購入や住宅ローンの返済、そして将来の退職後の生活設計まで。それぞれのイベントには、適切な資金準備が必要です。特に、私立中学校への進学費用や、退職後の生活資金については、長期的な視点での準備が重要となります。",
|
||||
"estimatedDuration": 35,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s3_planning",
|
||||
"text": "私たちファイナンシャルプランナーは、テスト太郎様ご家族の夢や目標を実現するために、最適なライフプランをご提案させていただきます。資産運用、保険の見直し、教育資金の準備、退職後の生活設計まで、トータルでサポートいたします。一緒に、安心で豊かな未来を築いていきましょう。",
|
||||
"estimatedDuration": 32,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2,
|
||||
"highlightArea": "full"
|
||||
},
|
||||
{
|
||||
"id": "s3_closing",
|
||||
"text": "ご清聴ありがとうございました。テスト様ご家族の明るい未来のために、これからも全力でサポートさせていただきます。",
|
||||
"estimatedDuration": 10,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2,
|
||||
"highlightArea": "full"
|
||||
}
|
||||
]
|
||||
|
||||
print(f" ✓ Created {len(segments)} detailed narration segments")
|
||||
|
||||
# Display preview
|
||||
print("\n 📝 Script Preview:")
|
||||
total_duration = 0
|
||||
for i, seg in enumerate(segments, 1):
|
||||
slide_num = seg['visualAssetIndex'] + 1
|
||||
duration = seg['estimatedDuration']
|
||||
total_duration += duration
|
||||
print(f"\n {i}. [{seg['id']}] - Slide {slide_num} ({duration}s)")
|
||||
print(f" {seg['text'][:70]}...")
|
||||
|
||||
print(f"\n 📊 Total estimated duration: {total_duration}s")
|
||||
|
||||
return segments
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio using OpenAI"""
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
|
||||
try:
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
except ImportError as e:
|
||||
print(f"\n⚠️ Import error: {e}")
|
||||
print(" Using estimated durations without audio generation")
|
||||
for seg in segments:
|
||||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||
return segments
|
||||
|
||||
print("\n🎙️ Generating audio with OpenAI TTS...")
|
||||
|
||||
try:
|
||||
audio_gen = AudioGenerator()
|
||||
except ValueError as e:
|
||||
print(f"\n⚠️ {e}")
|
||||
print(" Using estimated durations without audio generation")
|
||||
for seg in segments:
|
||||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||
return segments
|
||||
|
||||
output_dir = self.project_root / "public" / "ppt_intro_final_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments, 1):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i}/{len(segments)}] {seg['id']}: Generating...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/ppt_intro_final_audio/{audio_filename}"
|
||||
print(f" ✓ {actual_duration:.2f}s")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||
continue
|
||||
|
||||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||
print(f"\n ✓ Audio generation complete ({total_duration:.1f}s total)")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||
"""Create render_props configuration"""
|
||||
print("\n📦 Creating render props...")
|
||||
|
||||
# Load existing render_props for charts and assets
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
existing_props = json.load(f)
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": "テスト太郎様 ライフプラン紹介",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": "テスト太郎"
|
||||
},
|
||||
"charts": existing_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"slides": existing_props["assets"]["slides"],
|
||||
"videoClips": [],
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_ppt_intro_final.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" ✓ Saved to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print("=" * 70)
|
||||
print("🚀 PPT Introduction Video Generator (Final Version)")
|
||||
print("=" * 70)
|
||||
|
||||
try:
|
||||
# Create script
|
||||
segments = self.create_detailed_script()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ Generation Complete!")
|
||||
print("=" * 70)
|
||||
|
||||
output_file = "assets/2_output/ppt_intro_final_video.mp4"
|
||||
print(f"\n📹 Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_ppt_intro_final.json\" --concurrency=1")
|
||||
|
||||
print(f"\n📊 Video Summary:")
|
||||
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||
print(f" - Total segments: {len(segments)}")
|
||||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||
print(f" - Client: {props['meta']['clientName']}")
|
||||
print(f" - Config file: render_props_ppt_intro_final.json")
|
||||
|
||||
print(f"\n📋 Content Coverage:")
|
||||
print(f" ✓ 家族代表者(テスト太郎様)の詳細")
|
||||
print(f" ✓ 配偶者(花子様)の情報")
|
||||
print(f" ✓ お子様の教育プラン")
|
||||
print(f" ✓ 世帯収入・資産・支出")
|
||||
print(f" ✓ 将来のライフイベント")
|
||||
print(f" ✓ ライフプランニングの提案")
|
||||
|
||||
return output_file
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = PPTIntroFinalGenerator()
|
||||
generator.run()
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Generate introduction video for PPT slides 2 & 3 with API-based content generation
|
||||
- Extract detailed info from PDF using Gemini API
|
||||
- Generate rich narration based on PDF data
|
||||
- Only display PPT slides (no PDF page switching)
|
||||
- Generate audio using OpenAI TTS
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from dotenv import load_dotenv
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
# Load environment
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class PPTIntroVideoGenerator:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
|
||||
# Initialize Gemini client
|
||||
api_key = os.getenv("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||
self.client = genai.Client(api_key=api_key)
|
||||
|
||||
print("✓ Gemini API client initialized")
|
||||
|
||||
def extract_pdf_info(self) -> Dict:
|
||||
"""Extract detailed information from PDF using Gemini"""
|
||||
print("\n📄 Extracting information from PDF...")
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
|
||||
# Upload PDF to Gemini
|
||||
print(" Uploading PDF...")
|
||||
with open(pdf_path, 'rb') as f:
|
||||
upload_config = types.UploadFileConfig(mime_type='application/pdf')
|
||||
pdf_file = self.client.files.upload(file=f, config=upload_config)
|
||||
|
||||
# Wait for processing
|
||||
while pdf_file.state == "PROCESSING":
|
||||
print(" ⏳ Processing PDF...")
|
||||
time.sleep(2)
|
||||
pdf_file = self.client.files.get(name=pdf_file.name)
|
||||
|
||||
if pdf_file.state == "FAILED":
|
||||
raise RuntimeError(f"PDF processing failed: {pdf_file.error}")
|
||||
|
||||
print(" ✓ PDF uploaded successfully")
|
||||
|
||||
# Extraction prompt - focus on family and life events
|
||||
prompt = """
|
||||
このPDFから、家族紹介とライフイベントに関する詳細情報を抽出してください。
|
||||
|
||||
以下のJSON形式で出力してください:
|
||||
|
||||
{
|
||||
"family": {
|
||||
"representative": {
|
||||
"name": "代表者名",
|
||||
"age": 年齢,
|
||||
"birth_date": "生年月日",
|
||||
"occupation": "業種",
|
||||
"annual_income": 年収(万円),
|
||||
"description": "簡単な説明"
|
||||
},
|
||||
"spouse": {
|
||||
"name": "配偶者名",
|
||||
"age": 年齢,
|
||||
"birth_date": "生年月日",
|
||||
"occupation": "業種",
|
||||
"annual_income": 年収(万円),
|
||||
"description": "簡単な説明"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"name": "子供の呼称",
|
||||
"age": 年齢,
|
||||
"birth_date": "生年月日",
|
||||
"education_plan": "進学プラン(保育園→小学校→中学校など)",
|
||||
"description": "簡単な説明"
|
||||
}
|
||||
],
|
||||
"household": {
|
||||
"total_income": 世帯年収(万円),
|
||||
"annual_expenses": 年間支出(万円),
|
||||
"current_assets": 現在資産(万円),
|
||||
"savings_per_year": 年間貯蓄額(万円)
|
||||
}
|
||||
},
|
||||
"life_events": [
|
||||
{
|
||||
"year": イベント発生年,
|
||||
"age_representative": その時の代表者年齢,
|
||||
"event_name": "イベント名",
|
||||
"description": "詳細説明",
|
||||
"cost": 費用(万円),
|
||||
"category": "カテゴリー(教育/住宅/退職など)"
|
||||
}
|
||||
],
|
||||
"financial_outlook": {
|
||||
"retirement_age": 退職年齢,
|
||||
"life_expectancy": 想定寿命,
|
||||
"key_concerns": ["懸念事項1", "懸念事項2"],
|
||||
"opportunities": ["機会1", "機会2"]
|
||||
}
|
||||
}
|
||||
|
||||
可能な限り具体的で詳細な情報を抽出してください。
|
||||
"""
|
||||
|
||||
print(" Analyzing PDF content...")
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash-exp",
|
||||
contents=[pdf_file, prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
pdf_info = json.loads(response.text)
|
||||
print(" ✓ PDF information extracted")
|
||||
|
||||
# Print summary
|
||||
family = pdf_info.get("family", {})
|
||||
rep = family.get("representative", {})
|
||||
print(f"\n 📊 Extracted Info:")
|
||||
print(f" - Client: {rep.get('name')}")
|
||||
print(f" - Household income: {family.get('household', {}).get('total_income')}万円")
|
||||
print(f" - Life events: {len(pdf_info.get('life_events', []))} events")
|
||||
|
||||
return pdf_info
|
||||
|
||||
def generate_narration_script(self, pdf_info: Dict) -> List[Dict]:
|
||||
"""Generate narration script using Gemini based on PDF info"""
|
||||
print("\n🤖 Generating narration script...")
|
||||
|
||||
family = pdf_info.get("family", {})
|
||||
events = pdf_info.get("life_events", [])
|
||||
outlook = pdf_info.get("financial_outlook", {})
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、温かみのある動画ナレーターです。
|
||||
|
||||
【重要な制約】
|
||||
- PPTスライド2枚(slide_002.png と slide_003.png)のみを使用
|
||||
- スライド2: 家族基本情報(家族構成、年齢、職業、年収)
|
||||
- スライド3: ライフイベント(将来の予定)
|
||||
- 各スライドにつき2-4個のセグメントを作成
|
||||
- 合計5-7個のセグメントを生成
|
||||
|
||||
【抽出したPDF情報】
|
||||
■ 家族情報:
|
||||
{json.dumps(family, ensure_ascii=False, indent=2)}
|
||||
|
||||
■ ライフイベント(最初の5件):
|
||||
{json.dumps(events[:5] if events else [], ensure_ascii=False, indent=2)}
|
||||
|
||||
■ 将来展望:
|
||||
{json.dumps(outlook, ensure_ascii=False, indent=2)}
|
||||
|
||||
【要件】
|
||||
1. セグメント構造:
|
||||
- id: ユニークなID(例: "s2_intro", "s2_family", "s3_events")
|
||||
- text: ナレーション文(30-50秒分、温かく親しみやすい語り口)
|
||||
- estimatedDuration: 推定時間(秒、実際の音声生成後に更新されます)
|
||||
- visualType: "ppt" (固定)
|
||||
- visualAssetIndex: 1(スライド2)または 2(スライド3)
|
||||
- highlightArea: "full" (固定)
|
||||
|
||||
2. ナレーションのポイント:
|
||||
- PDFの具体的な数値を必ず含める(名前、年齢、年収、費用など)
|
||||
- 親しみやすく温かい語り口
|
||||
- 各セグメント30-50秒程度
|
||||
- 自然な流れで情報を伝える
|
||||
- 聞き手が理解しやすいように整理
|
||||
|
||||
3. セグメント配分例:
|
||||
【スライド2(家族情報)】
|
||||
- セグメント1: 家族紹介の導入(10-15秒)
|
||||
- セグメント2: 代表者の詳細(25-35秒)
|
||||
- セグメント3: 配偶者と子供の詳細(30-40秒)
|
||||
- セグメント4: 家計サマリー(20-30秒)
|
||||
|
||||
【スライド3(ライフイベント)】
|
||||
- セグメント5: イベント概要の導入(10-15秒)
|
||||
- セグメント6: 具体的なイベント紹介(35-45秒)
|
||||
- セグメント7: 将来への展望(20-30秒)
|
||||
|
||||
4. 推定時間の設定:
|
||||
- 日本語の読み上げ速度: 約300-350文字/分(5-6文字/秒)
|
||||
- テキストの長さから適切な時間を推定
|
||||
|
||||
【出力】
|
||||
JSON配列のみを返してください。説明文は不要です。
|
||||
|
||||
例:
|
||||
[
|
||||
{{
|
||||
"id": "s2_intro",
|
||||
"text": "それでは、テスト太郎様のご家族についてご紹介いたします...",
|
||||
"estimatedDuration": 12,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full"
|
||||
}},
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash-exp",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.8,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
segments = json.loads(response.text)
|
||||
print(f" ✓ Generated {len(segments)} narration segments")
|
||||
|
||||
# Display preview
|
||||
print("\n 📝 Script Preview:")
|
||||
for i, seg in enumerate(segments, 1):
|
||||
slide_num = seg['visualAssetIndex'] + 1
|
||||
print(f"\n {i}. [{seg['id']}] - Slide {slide_num} ({seg.get('estimatedDuration', 0)}s)")
|
||||
print(f" {seg['text'][:80]}...")
|
||||
|
||||
return segments
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio using OpenAI"""
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
print("\n🎙️ Generating audio with OpenAI TTS...")
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = self.project_root / "public" / "ppt_intro_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments, 1):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i}/{len(segments)}] {seg['id']}: Generating...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/ppt_intro_audio/{audio_filename}"
|
||||
print(f" ✓ {actual_duration:.2f}s")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||
continue
|
||||
|
||||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||
print(f"\n ✓ Generated {len(segments)} audio files ({total_duration:.1f}s total)")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||
"""Create render_props configuration"""
|
||||
print("\n📦 Creating render props...")
|
||||
|
||||
# Load existing render_props for charts and assets
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
existing_props = json.load(f)
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": "ライフプラン紹介 - 家族とイベント",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": existing_props["meta"].get("clientName", "お客様")
|
||||
},
|
||||
"charts": existing_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"slides": existing_props["assets"]["slides"],
|
||||
"videoClips": [],
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_ppt_intro.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" ✓ Saved to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print("=" * 70)
|
||||
print("🚀 PPT Introduction Video Generator")
|
||||
print("=" * 70)
|
||||
|
||||
try:
|
||||
# Extract PDF info
|
||||
pdf_info = self.extract_pdf_info()
|
||||
|
||||
# Generate script
|
||||
segments = self.generate_narration_script(pdf_info)
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ Generation Complete!")
|
||||
print("=" * 70)
|
||||
|
||||
output_file = "assets/2_output/ppt_intro_video.mp4"
|
||||
print(f"\n📹 Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_ppt_intro.json\" --concurrency=1")
|
||||
|
||||
print(f"\n📊 Video Summary:")
|
||||
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||
print(f" - Total segments: {len(segments)}")
|
||||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||
print(f" - Client: {props['meta']['clientName']}")
|
||||
print(f" - Config file: render_props_ppt_intro.json")
|
||||
|
||||
return output_file
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = PPTIntroVideoGenerator()
|
||||
generator.run()
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Generate video for PPT slides 2 & 3 using existing data (no API calls needed)
|
||||
Only display PPT slides, narration includes PDF info
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class SimplePPTVideoGenerator:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
|
||||
def create_script_segments(self) -> List[Dict]:
|
||||
"""
|
||||
Create narration script for slides 2 and 3
|
||||
Based on existing PDF/PPT data
|
||||
Using existing audio files from family_intro_audio
|
||||
"""
|
||||
|
||||
# Use existing audio files
|
||||
segments = [
|
||||
{
|
||||
"id": "slide2_intro",
|
||||
"text": "それでは、テスト太郎様のご家族についてご紹介いたします。こちらのスライドをご覧ください。",
|
||||
"duration": 8.976,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1, # slide_002.png
|
||||
"highlightArea": "full",
|
||||
"audioPath": "public/family_intro_audio/narration_intro01.mp3"
|
||||
},
|
||||
{
|
||||
"id": "slide2_family_overview",
|
||||
"text": "今回ご紹介するのは、テスト太郎さん、花子さん、そして7歳になる息子さんの3人家族です。太郎さんは情報通信業にお勤めで、年収は703万円。花子さんは卸売・小売業で、年収は368万円です。素敵なご家族ですね。",
|
||||
"duration": 19.248,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full",
|
||||
"audioPath": "public/family_intro_audio/narration_family01.mp3"
|
||||
},
|
||||
{
|
||||
"id": "slide2_child_info",
|
||||
"text": "それでは、ご家族の詳細を見ていきましょう。まず、お父様のテスト太郎さんは、1980年1月1日生まれの47歳。情報通信業でご活躍されています。",
|
||||
"duration": 30.264,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 1,
|
||||
"highlightArea": "full",
|
||||
"audioPath": "public/family_intro_audio/narration_details01.mp3"
|
||||
},
|
||||
{
|
||||
"id": "slide3_transition",
|
||||
"text": "テストさんご一家の世帯年収は1071万円。将来の夢に向かって、素晴らしいライフプランを描いていきましょう。",
|
||||
"duration": 9.528,
|
||||
"visualType": "ppt",
|
||||
"visualAssetIndex": 2, # slide_003.png
|
||||
"highlightArea": "full",
|
||||
"audioPath": "public/family_intro_audio/narration_summary01.mp3"
|
||||
}
|
||||
]
|
||||
|
||||
print(f"✓ Created {len(segments)} script segments (using existing audio)")
|
||||
print("\n📝 Script Preview:")
|
||||
for i, seg in enumerate(segments, 1):
|
||||
print(f"\n{i}. [{seg['id']}] - Slide {seg['visualAssetIndex'] + 1}")
|
||||
print(f" Duration: {seg['duration']}s")
|
||||
print(f" Audio: {seg['audioPath']}")
|
||||
print(f" Text: {seg['text'][:60]}...")
|
||||
|
||||
return segments
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio for each segment"""
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
|
||||
print("\n🎙️ Checking audio generation capability...")
|
||||
|
||||
# Check if API key is available
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print(" ⚠️ OPENAI_API_KEY not found - skipping audio generation")
|
||||
print(" 📝 Using preset durations for video segments")
|
||||
return segments
|
||||
|
||||
try:
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
except ImportError as e:
|
||||
print(f" ⚠️ Import error: {e}")
|
||||
print(" 📝 Using preset durations without audio generation")
|
||||
return segments
|
||||
|
||||
print("🎙️ Generating audio...")
|
||||
|
||||
try:
|
||||
audio_gen = AudioGenerator()
|
||||
except ValueError as e:
|
||||
print(f" ⚠️ {e}")
|
||||
print(" 📝 Using preset durations for video segments")
|
||||
return segments
|
||||
|
||||
output_dir = self.project_root / "public" / "ppt_slides_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i+1}/{len(segments)}] {seg['id']}: Generating audio...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/ppt_slides_audio/{audio_filename}"
|
||||
print(f" ✓ Duration: {actual_duration:.2f}s")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Audio generation failed: {e}")
|
||||
print(f" Using preset duration: {seg['duration']}s")
|
||||
continue
|
||||
|
||||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||
print(f"\n✓ Audio processing complete (Total: {total_duration:.1f}s)")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||
"""Create render_props with only PPT slides"""
|
||||
|
||||
# Load existing render_props to get charts and assets
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
existing_props = json.load(f)
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": "テスト太郎様 ライフプラン紹介",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": "テスト太郎"
|
||||
},
|
||||
"charts": existing_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"slides": existing_props["assets"]["slides"],
|
||||
"videoClips": [],
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_ppt_slides.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✓ Saved render props to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print("🚀 Generating video for PPT slides 2 & 3...\n")
|
||||
|
||||
# Create script segments
|
||||
segments = self.create_script_segments()
|
||||
print()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
print()
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments)
|
||||
print()
|
||||
|
||||
output_file = "assets/2_output/ppt_slides_video.mp4"
|
||||
print(f"✅ Pipeline complete!")
|
||||
print(f"\n📹 Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} --props=\"assets/1_processed/render_props_ppt_slides.json\" --concurrency=1")
|
||||
print()
|
||||
print(f"📊 Video Summary:")
|
||||
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||
print(f" - Total segments: {len(segments)}")
|
||||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||
print(f" - Client: {props['meta']['clientName']}")
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = SimplePPTVideoGenerator()
|
||||
generator.run()
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Generate video for specific PPT slides (slides 2 & 3)
|
||||
Extract information from PDF but only display PPT slides
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from dotenv import load_dotenv
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
# Load environment
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class PPTSlidesVideoGenerator:
|
||||
def __init__(self, slide_indices: List[int] = [1, 2]):
|
||||
"""
|
||||
Initialize generator for specific PPT slides
|
||||
|
||||
Args:
|
||||
slide_indices: List of slide indices to generate video for (0-based)
|
||||
Default: [1, 2] means slide 2 and slide 3 (slide_002.png, slide_003.png)
|
||||
"""
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.slide_indices = slide_indices
|
||||
|
||||
# Initialize Gemini client
|
||||
api_key = os.getenv("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||
self.client = genai.Client(api_key=api_key)
|
||||
|
||||
def load_materials(self) -> Dict:
|
||||
"""Load PPT slides and PDF data"""
|
||||
print("📦 Loading materials...")
|
||||
|
||||
# Load existing render_props to get PPT slides info
|
||||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
props_data = json.load(f)
|
||||
|
||||
all_slides = props_data["assets"]["slides"]
|
||||
target_slides = [slide for slide in all_slides if slide["index"] in self.slide_indices]
|
||||
|
||||
print(f"✓ Loaded {len(target_slides)} PPT slides (indices: {self.slide_indices})")
|
||||
|
||||
# Extract PDF data using Gemini
|
||||
pdf_data = self.extract_pdf_data()
|
||||
|
||||
return {
|
||||
"ppt_slides": target_slides,
|
||||
"pdf_data": pdf_data,
|
||||
"existing_props": props_data
|
||||
}
|
||||
|
||||
def extract_pdf_data(self) -> Dict:
|
||||
"""Extract relevant data from PDF using Gemini"""
|
||||
print("📄 Extracting data from PDF...")
|
||||
|
||||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||
|
||||
# Upload PDF to Gemini
|
||||
with open(pdf_path, 'rb') as f:
|
||||
upload_config = types.UploadFileConfig(mime_type='application/pdf')
|
||||
pdf_file = self.client.files.upload(file=f, config=upload_config)
|
||||
|
||||
# Wait for processing
|
||||
import time
|
||||
while pdf_file.state == "PROCESSING":
|
||||
print(" ⏳ Processing PDF...")
|
||||
time.sleep(2)
|
||||
pdf_file = self.client.files.get(name=pdf_file.name)
|
||||
|
||||
if pdf_file.state == "FAILED":
|
||||
raise RuntimeError(f"PDF processing failed: {pdf_file.error}")
|
||||
|
||||
print(" ✓ PDF uploaded successfully")
|
||||
|
||||
# Extraction prompt
|
||||
prompt = """
|
||||
このPDFからライフプラン情報を詳細に抽出してください。
|
||||
|
||||
以下のJSON形式で出力してください:
|
||||
|
||||
{
|
||||
"client_info": {
|
||||
"name": "クライアント名",
|
||||
"birth_date": "生年月日",
|
||||
"age": 年齢,
|
||||
"occupation": "業種",
|
||||
"annual_income": 年収(万円)
|
||||
},
|
||||
"spouse_info": {
|
||||
"name": "配偶者名",
|
||||
"birth_date": "生年月日",
|
||||
"age": 年齢,
|
||||
"occupation": "業種",
|
||||
"annual_income": 年収(万円)
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"name": "子供の名前",
|
||||
"birth_date": "生年月日",
|
||||
"age": 年齢,
|
||||
"education_plan": "教育プラン(保育園→小学校→中学校など)"
|
||||
}
|
||||
],
|
||||
"financial_summary": {
|
||||
"household_income": 世帯年収(万円),
|
||||
"annual_expenses": 年間支出(万円),
|
||||
"current_assets": 現在資産(万円),
|
||||
"savings": 貯蓄額(万円)
|
||||
},
|
||||
"life_events": [
|
||||
{
|
||||
"year": イベント発生年,
|
||||
"age": その時の年齢,
|
||||
"description": "イベント説明",
|
||||
"cost": 費用(万円)
|
||||
}
|
||||
],
|
||||
"future_projections": {
|
||||
"retirement_age": 退職年齢,
|
||||
"life_expectancy": 想定寿命,
|
||||
"retirement_savings_needed": 必要な老後資金(万円)
|
||||
}
|
||||
}
|
||||
|
||||
可能な限り詳細な情報を抽出してください。数値は必ず含めてください。
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[pdf_file, prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
pdf_data = json.loads(response.text)
|
||||
print("✓ PDF data extracted successfully")
|
||||
return pdf_data
|
||||
|
||||
def generate_script(self, materials: Dict) -> List[Dict]:
|
||||
"""Generate narration script for PPT slides based on PDF data"""
|
||||
print("🤖 Generating narration script...")
|
||||
|
||||
ppt_slides = materials["ppt_slides"]
|
||||
pdf_data = materials["pdf_data"]
|
||||
|
||||
# Handle case where pdf_data might be a list or dict
|
||||
if isinstance(pdf_data, list):
|
||||
pdf_data = pdf_data[0] if pdf_data else {}
|
||||
|
||||
# Build detailed context from PDF
|
||||
client_info = pdf_data.get("client_info", {})
|
||||
spouse_info = pdf_data.get("spouse_info", {})
|
||||
children = pdf_data.get("children", [])
|
||||
financial = pdf_data.get("financial_summary", {})
|
||||
life_events = pdf_data.get("life_events", [])
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||||
|
||||
【重要】以下の2枚のPPTスライドについて、PDFから抽出した詳細情報を使って、
|
||||
豊富で魅力的なナレーション原稿を作成してください。
|
||||
|
||||
【利用可能なPPTスライド】
|
||||
スライド1 (slide_002.png): インデックス={ppt_slides[0]["index"]}
|
||||
スライド2 (slide_003.png): インデックス={ppt_slides[1]["index"] if len(ppt_slides) > 1 else "N/A"}
|
||||
|
||||
【PDFから抽出した詳細情報】
|
||||
■ クライアント情報:
|
||||
{json.dumps(client_info, ensure_ascii=False, indent=2)}
|
||||
|
||||
■ 配偶者情報:
|
||||
{json.dumps(spouse_info, ensure_ascii=False, indent=2)}
|
||||
|
||||
■ 子供情報:
|
||||
{json.dumps(children, ensure_ascii=False, indent=2)}
|
||||
|
||||
■ 財務サマリー:
|
||||
{json.dumps(financial, ensure_ascii=False, indent=2)}
|
||||
|
||||
■ ライフイベント (最初の5件):
|
||||
{json.dumps(life_events[:5], ensure_ascii=False, indent=2)}
|
||||
|
||||
【要件】
|
||||
1. **2枚のPPTスライドのみを使用**してください
|
||||
- スライド1枚あたり2-4個のセグメントを作成
|
||||
- 合計で4-6個のセグメントを生成
|
||||
|
||||
2. 各セグメントの構造:
|
||||
- id: セグメントID (例: "slide2_intro", "slide2_family", "slide3_events")
|
||||
- text: ナレーション文 (30-60秒分の自然な日本語、具体的な数値を多く含む)
|
||||
- duration: 推定時間(秒、実際の音声生成後に更新されます)
|
||||
- visualType: "ppt" (固定)
|
||||
- visualAssetIndex: PPTスライドのインデックス (0始まり)
|
||||
- highlightArea: "full" (固定)
|
||||
|
||||
3. **ナレーション作成のポイント**:
|
||||
- PDFから抽出した具体的な数値を必ず使用(名前、年齢、生年月日、年収、業種など)
|
||||
- 温かみのある親しみやすい語り口
|
||||
- 視聴者が理解しやすいように、情報を整理して伝える
|
||||
- 各セグメント30-60秒程度の適切な長さ
|
||||
- 同じスライドを連続して使用しても良い(内容が異なれば)
|
||||
|
||||
4. **セグメント例**:
|
||||
- スライド2の前半: 家族構成の紹介
|
||||
- スライド2の後半: 各メンバーの詳細(年齢、職業、年収など)
|
||||
- スライド3の前半: ライフイベントの概要
|
||||
- スライド3の後半: 具体的なイベントと費用
|
||||
|
||||
**出力形式**: JSON配列のみを返してください。
|
||||
"""
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.7,
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
segments = json.loads(response.text)
|
||||
print(f"✓ Generated {len(segments)} script segments")
|
||||
|
||||
# Display preview
|
||||
print("\n📝 Script Preview:")
|
||||
for i, seg in enumerate(segments, 1):
|
||||
print(f"\n{i}. [{seg['id']}] - Slide {seg['visualAssetIndex'] + 1}")
|
||||
print(f" Text: {seg['text'][:100]}...")
|
||||
|
||||
return segments
|
||||
|
||||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||
"""Generate TTS audio for each segment"""
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
print("\n🎙️ Generating audio...")
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = self.project_root / "public" / "ppt_slides_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f" [{i+1}/{len(segments)}] {seg['id']}: Generating audio...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/ppt_slides_audio/{audio_filename}"
|
||||
print(f" ✓ Duration: {actual_duration:.2f}s")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
seg["duration"] = 5.0 # Fallback duration
|
||||
continue
|
||||
|
||||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||
print(f"\n✓ Generated {len(segments)} audio files (Total: {total_duration:.1f}s)")
|
||||
return segments
|
||||
|
||||
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||
"""Create render_props with only PPT slides"""
|
||||
|
||||
existing_props = materials["existing_props"]
|
||||
|
||||
new_props = {
|
||||
"meta": {
|
||||
"title": f"ライフプラン紹介 - スライド{self.slide_indices[0]+1}・{self.slide_indices[1]+1 if len(self.slide_indices) > 1 else ''}",
|
||||
"currencyUnit": "万円",
|
||||
"duration": sum(s.get("duration", 0) for s in segments),
|
||||
"clientName": materials["pdf_data"].get("client_info", {}).get("name", "お客様")
|
||||
},
|
||||
"charts": existing_props.get("charts", {}),
|
||||
"script": segments,
|
||||
"assets": {
|
||||
"slides": existing_props["assets"]["slides"], # Keep all slides
|
||||
"videoClips": [],
|
||||
"broll": existing_props["assets"].get("broll", {})
|
||||
}
|
||||
}
|
||||
|
||||
output_path = self.project_root / "assets/1_processed/render_props_ppt_slides.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✓ Saved render props to {output_path.name}")
|
||||
return new_props
|
||||
|
||||
def run(self) -> str:
|
||||
"""Execute full pipeline"""
|
||||
print(f"🚀 Generating video for PPT slides {[i+1 for i in self.slide_indices]}...\n")
|
||||
|
||||
# Load materials
|
||||
materials = self.load_materials()
|
||||
print()
|
||||
|
||||
# Generate script
|
||||
segments = self.generate_script(materials)
|
||||
print()
|
||||
|
||||
# Generate audio
|
||||
segments = self.generate_audio(segments)
|
||||
print()
|
||||
|
||||
# Create render props
|
||||
props = self.create_render_props(segments, materials)
|
||||
print()
|
||||
|
||||
output_file = "assets/2_output/ppt_slides_video.mp4"
|
||||
print(f"✅ Pipeline complete!")
|
||||
print(f"\n📹 Render command:")
|
||||
print(f" npx remotion render MainVideo {output_file} \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_ppt_slides.json\" --concurrency=1")
|
||||
print()
|
||||
print(f"📊 Video Summary:")
|
||||
print(f" - Target slides: {[f'slide_{i+1:03d}.png' for i in self.slide_indices]}")
|
||||
print(f" - Total segments: {len(segments)}")
|
||||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||
print(f" - Client: {props['meta']['clientName']}")
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Generate video for slides 2 and 3 (indices 1 and 2)
|
||||
generator = PPTSlidesVideoGenerator(slide_indices=[1, 2])
|
||||
generator.run()
|
||||
|
|
@ -53,7 +53,7 @@ def rebuild_audio_from_props():
|
|||
|
||||
# Generate new audio files (outputs to public/ folder)
|
||||
print("🎙️ Generating new audio files...")
|
||||
updated_script_data = generate_audio_for_script(script_data, voice="fable")
|
||||
updated_script_data = generate_audio_for_script(script_data, voice="nova")
|
||||
# voice options: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
|
||||
|
||||
# Update the script in our props object
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Regenerate missing audio files for PPT intro video
|
||||
Only regenerates audio for segments that failed previously
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def regenerate_missing_audio():
|
||||
"""Regenerate audio for segments that are missing audioPath"""
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
# Load the render props
|
||||
props_path = project_root / "assets/1_processed/render_props_ppt_intro_final.json"
|
||||
with open(props_path, encoding="utf-8") as f:
|
||||
props_data = json.load(f)
|
||||
|
||||
segments = props_data["script"]
|
||||
|
||||
# Find segments without audioPath
|
||||
missing_segments = []
|
||||
for seg in segments:
|
||||
if "audioPath" not in seg or not seg["audioPath"]:
|
||||
missing_segments.append(seg)
|
||||
|
||||
if not missing_segments:
|
||||
print("✓ All segments already have audio files!")
|
||||
return
|
||||
|
||||
print(f"🔍 Found {len(missing_segments)} segments without audio")
|
||||
|
||||
# Import audio generator
|
||||
sys.path.insert(0, str(project_root))
|
||||
from backend.audio_generator import AudioGenerator
|
||||
from backend.video_processor.utils import get_video_duration
|
||||
|
||||
audio_gen = AudioGenerator()
|
||||
output_dir = project_root / "public" / "ppt_intro_final_audio"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Regenerate audio
|
||||
print("\n🎙️ Generating missing audio files...")
|
||||
|
||||
for i, seg in enumerate(missing_segments, 1):
|
||||
text = seg["text"]
|
||||
audio_filename = f"narration_{seg['id']}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
print(f"\n[{i}/{len(missing_segments)}] {seg['id']}")
|
||||
print(f" Text: {text[:60]}...")
|
||||
print(f" Generating audio...")
|
||||
|
||||
try:
|
||||
response = audio_gen.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="nova",
|
||||
input=text
|
||||
)
|
||||
response.stream_to_file(str(audio_path))
|
||||
|
||||
actual_duration = get_video_duration(str(audio_path))
|
||||
seg["duration"] = actual_duration
|
||||
seg["audioPath"] = f"public/ppt_intro_final_audio/{audio_filename}"
|
||||
print(f" ✓ Generated: {actual_duration:.2f}s")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed: {e}")
|
||||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||
continue
|
||||
|
||||
# Update duration in props
|
||||
props_data["meta"]["duration"] = sum(s.get("duration", 0) for s in segments)
|
||||
|
||||
# Save updated props
|
||||
with open(props_path, "w", encoding="utf-8") as f:
|
||||
json.dump(props_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ Regeneration complete!")
|
||||
print(f" Total duration: {props_data['meta']['duration']:.1f}s")
|
||||
print(f"\n📹 Ready to render:")
|
||||
print(f" npx remotion render MainVideo assets/2_output/ppt_intro_final_video.mp4 \\")
|
||||
print(f" --props=\"assets/1_processed/render_props_ppt_intro_final.json\" --concurrency=1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
regenerate_missing_audio()
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -118,9 +118,17 @@ class ScriptGenerator:
|
|||
for i, clip in enumerate(clips):
|
||||
video_info += f"- クリップ {i}: 「{clip.get('text', '')[:100]}...」\n"
|
||||
|
||||
# Advice info
|
||||
advice_info = ""
|
||||
advice_list = pdf_data.get("advice", [])
|
||||
if advice_list:
|
||||
advice_info = "\n【具体的なアドバイス内容】\n"
|
||||
for adv in advice_list:
|
||||
advice_info += f"- {adv}\n"
|
||||
|
||||
prompt = f"""
|
||||
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||||
以下の顧客情報、ライフイベント、PPTスライド、そしてインタビュー動画の転記内容に基づいて、解説動画用の完結なナレーション原稿を作成してください。
|
||||
以下の顧客情報、ライフイベント、PPTスライド、具体的なアドバイス、そしてインタビュー動画の転記内容に基づいて、解説動画用の完結なナレーション原稿を作成してください。
|
||||
|
||||
【厳格な指示】
|
||||
1. 抽象的な表現(「〇〇」や「△△」)や、メタ発言(「スライドの内容がありません」など)は絶対に出力しないでください。
|
||||
|
|
@ -146,6 +154,7 @@ class ScriptGenerator:
|
|||
- 年間支出: {annual_expenses}万円
|
||||
{events_info}
|
||||
{ppt_info}
|
||||
{advice_info}
|
||||
{video_info}
|
||||
|
||||
【原稿構成のフロー】
|
||||
|
|
@ -156,8 +165,9 @@ class ScriptGenerator:
|
|||
3. ライフイベント(Slide 3, 4...):具体的なイベント内容の解説。
|
||||
4. インタビュー挿入(Video):本人の想いや不安、决意を语るシーン。
|
||||
5. シミュレーション詳細(Chart):動的なグラフ(資産推移・収支明細)を使い、何年後に資産がどうなるかを具体的に説明。
|
||||
6. 対策とアドバイス(Slide 5, 6...):改善策や提案をスライドで。
|
||||
7. クロージング(Slide 7...):まとめと個別相談の案内。
|
||||
6. 対策とアドバイス(Slide 5, 6...):【具体的なアドバイス内容】を最大限に活用し、顧客の状況に合わせた具体的な改善策(保険の見直し、投資の検討、支出の最適化など)を 2〜3 セグメントかけて丁寧に説明してください。
|
||||
- **重要**: アドバイスセグメントでは、**`visualType` を必ず `ppt`** に設定し、**`visualAssetIndex` を `5`**(本件PPTの倒数2枚目:改善策スライド)に固定して表示し続けてください。決して一言で終わらせないでください。
|
||||
7. クロージング(Slide 7...):まとめと個別相談の案内。`visualAssetIndex` を `6`(最後のスライド)に設定してください。
|
||||
|
||||
【素材使用の鉄則 - 必ず守ること】
|
||||
- **PPTスライドの全消化**: 提供されたスライド(Slide 0 から最後のリファレンスまで)を、内容に合わせて**必ず全て最低1回は表示**してください。スライドを飛ばさないでください。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def simplify_intro():
|
||||
props_path = Path("assets/1_processed/render_props.json")
|
||||
if not props_path.exists():
|
||||
print("Error: render_props.json not found")
|
||||
return
|
||||
|
||||
with open(props_path, "r", encoding="utf-8") as f:
|
||||
props = json.load(f)
|
||||
|
||||
script = props.get("script", [])
|
||||
if len(script) < 6:
|
||||
print("Script too short to simplify")
|
||||
return
|
||||
|
||||
# Combined FP Segment (ID 1)
|
||||
# Merging content from original 1, 3, 5
|
||||
fp_text = "お客様のライフプランを提供するために、お名前と生年月日などの基本情報を教えていただけますでしょうか。"
|
||||
fp_segment = {
|
||||
"id": 1,
|
||||
"text": fp_text,
|
||||
"insight": "基本情報のヒアリング",
|
||||
"targetYear": 2024,
|
||||
"duration": 6.5, # Estimated, rebuild_audio will fix it
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "FP",
|
||||
"brollKeyword": "Japanese financial advisor consulting client",
|
||||
"audioPath": "public/segment_001.mp3"
|
||||
}
|
||||
|
||||
# Combined Client Segment (ID 2)
|
||||
# Merging content from original 2, 4, 6
|
||||
client_text = "はい、テスト太郎です。1980年1月1日生まれの47歳です。"
|
||||
client_segment = {
|
||||
"id": 2,
|
||||
"text": client_text,
|
||||
"insight": "自己紹介と基本情報",
|
||||
"targetYear": 2024,
|
||||
"duration": 8.0, # Estimated
|
||||
"type": "dialogue",
|
||||
"visualType": "dialogue",
|
||||
"speaker": "家族代表様",
|
||||
"brollKeyword": "Japanese man introduced himself",
|
||||
"audioPath": "public/segment_002.mp3" # Will use TTS for this combined one for now
|
||||
}
|
||||
|
||||
# New script starts with these two
|
||||
new_script = [fp_segment, client_segment]
|
||||
|
||||
# Append the rest (starting from original index 6, which is ID 7)
|
||||
for i, seg in enumerate(script[6:]):
|
||||
seg["id"] = i + 3
|
||||
# We'll let rebuild_audio fix the paths and durations
|
||||
new_script.append(seg)
|
||||
|
||||
props["script"] = new_script
|
||||
|
||||
with open(props_path, "w", encoding="utf-8") as f:
|
||||
json.dump(props, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print("Successfully simplified intro dialogue in render_props.json")
|
||||
|
||||
if __name__ == "__main__":
|
||||
simplify_intro()
|
||||
20
main.py
20
main.py
|
|
@ -102,6 +102,15 @@ class VideoGeneratorPipeline:
|
|||
|
||||
# Step 3: Generate narration script
|
||||
print("\n✍️ Step 3/6: Generating narration script...")
|
||||
props_file = self.processed_dir / "render_props.json"
|
||||
|
||||
# FEATURE: Use existing script if skip_script is enabled to preserve manual edits
|
||||
if self.config.get("skip_script") and props_file.exists():
|
||||
print(f" ♻️ PRESERVING existing script from {props_file.name}")
|
||||
with open(props_file, "r", encoding="utf-8") as f:
|
||||
existing_props = json.load(f)
|
||||
script = {"segments": existing_props.get("script", [])}
|
||||
else:
|
||||
script = self.generate_script(pdf_data, ppt_data, video_data)
|
||||
|
||||
# Step 3.5: Generate audio from script (NEW)
|
||||
|
|
@ -258,7 +267,9 @@ class VideoGeneratorPipeline:
|
|||
# 1. Extract keywords explicitly requested in script segments
|
||||
keywords = set()
|
||||
for segment in script.get("segments", []):
|
||||
if segment.get("visualType") == "broll" and segment.get("brollKeyword"):
|
||||
# ENHANCED: Collect keywords from ANY segment that has one,
|
||||
# as our 3-column layout can show B-Roll alongside PPT/Charts
|
||||
if segment.get("brollKeyword"):
|
||||
keywords.add(segment.get("brollKeyword"))
|
||||
|
||||
# 2. Add keywords from video data as fallback/supplement
|
||||
|
|
@ -425,6 +436,12 @@ Examples:
|
|||
help="Extract video clips from source video (optional)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-script",
|
||||
action="store_true",
|
||||
help="Skip AI script generation and use existing script from render_props.json (preserves manual edits)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Build configuration
|
||||
|
|
@ -444,6 +461,7 @@ Examples:
|
|||
|
||||
config["dry_run"] = args.dry_run
|
||||
config["extract_clips"] = args.extract_clips
|
||||
config["skip_script"] = args.skip_script
|
||||
|
||||
# Run pipeline
|
||||
pipeline = VideoGeneratorPipeline(config)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue