addashboard-cli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -190,6 +190,11 @@
190
190
  .ddrow:hover{background:var(--accent-soft);}
191
191
  .ddrow.cur{background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent);}
192
192
  .ddrow .ddno{font-family:var(--mono); color:var(--accent); font-weight:700; flex:0 0 auto; min-width:40px;}
193
+ /* 一覧の末尾に置く「+ 新規登録」。既存の番号行と地続きに見えないよう上の区切りを強くする */
194
+ .ddrow.ddnew{border-top:1px solid var(--line); border-bottom:none; background:var(--soft);
195
+ position:sticky; bottom:0; font-weight:600;}
196
+ .ddrow.ddnew:hover{background:var(--accent-soft);}
197
+ .ddrow.ddnew .ddno{font-size:15px;}
193
198
  .ddrow .ddtx{color:var(--fg); overflow:hidden; text-overflow:ellipsis;}
194
199
  /* サムネ (#157)。Finder のリスト表示に寄せて「小さい画像 + 名前」の1行にする。
195
200
  画像が無いアカウントでも崩れないよう、読み込み失敗時は onerror で枠ごと畳む。 */
@@ -344,6 +349,24 @@
344
349
  .mrow{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
345
350
  .modal .mfoot{display:flex; justify-content:flex-end; gap:8px; padding:14px 20px 18px; border-top:1px solid var(--line2); margin-top:8px; position:sticky; bottom:0; background:#fff;}
346
351
  .hint{font-size:11.5px; color:var(--faint); margin-top:4px;}
352
+ /* 「+ 新規登録」の2ステップ (保存フォルダ → 素材) */
353
+ .nmstep{border:1px solid var(--line2); border-radius:10px; padding:12px 14px; margin:10px 0;}
354
+ .nmstep.cur{border-color:var(--accent);}
355
+ .nmstep.wait{opacity:.55;}
356
+ .nmstep .nmhead{display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600;}
357
+ .nmstep .nmno{display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; background:var(--soft); font-size:11.5px;}
358
+ .nmstep.done .nmno{background:var(--green-soft); color:var(--green);}
359
+ .nmstep .nmbody{display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:8px; font-size:12.5px;}
360
+ .nmstep .nmbody .hint{flex-basis:100%; margin-top:0;}
361
+ .nmstep .nmpath{flex-basis:100%; display:flex; align-items:center; gap:8px; background:var(--soft); border-radius:8px; padding:8px 10px;}
362
+ .nmstep .nmpath .mono{flex:1; font-size:12px; word-break:break-all;}
363
+ .nmstep .nmhead .pill{font-weight:500; margin-left:4px;}
364
+ .nmitem{display:flex; gap:12px; align-items:flex-start; border:1px solid var(--line2); border-radius:10px; padding:10px; margin:8px 0;}
365
+ .nmitem img{width:120px; border:1px solid var(--line); border-radius:4px; flex:0 0 auto;}
366
+ .nmitem .nmmeta{flex:1; min-width:0;}
367
+ .nmitem .nmfile{font-size:12px; word-break:break-all; margin-bottom:6px;}
368
+ .nmitem .nmfields{display:flex; gap:10px; flex-wrap:wrap; font-size:12px;}
369
+ .nmitem .nmfields input{width:90px; font-size:13px; border:1px solid var(--line); border-radius:6px; padding:4px 6px;}
347
370
  .toast{position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:#111418; color:#fff; font-size:12.5px; padding:9px 16px; border-radius:9px; z-index:100; box-shadow:0 8px 24px rgba(0,0,0,.25); opacity:0; transition:opacity .2s, transform .2s; max-width:min(720px,92vw); text-align:left; line-height:1.6;}
348
371
  .toast.show{opacity:1; transform:translateX(-50%) translateY(-2px);}
349
372
  /* 書き出しをブロックしたときは自動で消さない。原因を読み返せないと直しようがない */
@@ -424,6 +447,7 @@
424
447
  <button data-yt="overview" class="active">概要</button>
425
448
  <button data-yt="resp">レスポンシブ</button>
426
449
  <button data-yt="banner">バナー</button>
450
+ <button data-yt="video">動画</button>
427
451
  <button data-yt="param">パラメータ</button>
428
452
  <button data-yt="guide">使い方</button>
429
453
  </div>
@@ -487,7 +511,7 @@
487
511
  <table class="g"><thead><tr><th>形式</th><th>入稿経路</th><th>利用</th><th>備考</th></tr></thead><tbody>
488
512
  <tr><td><b>レスポンシブ(画像)・固定型</b></td><td><span class="pill green">API入稿 ★主用途</span></td><td>★★★</td><td>タイトル/説明文/画像を人が1通りに固定し、成果を自分で検証。<code>Card</code>(1200x628)/<code>SQ</code>(1080x1080) の2形式。API上は <code>RESPONSIVE_AD + mainMediaFormat:IMAGE</code>、画像は <code>asset.mediaAssets[].mediaId</code>(PR #82・実API疎通は検証中)</td></tr>
489
513
  <tr><td>レスポンシブ(画像)・アセット型</td><td><span class="pill amber">非対象</span></td><td>—</td><td>複数タイトル/画像を登録しYahoo側が自動最適化。<b>1広告=単一組合せの本ツールでは非対象</b></td></tr>
490
- <tr><td>レスポンシブ(動画)</td><td><span class="pill gray">別フロー(CLI)</span></td><td>—</td><td><code>webgram upload-video → submit</code> のAPI入稿(本番実績あり)。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
514
+ <tr><td><b>レスポンシブ(動画)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★☆</td><td>広告行の<b>形式列で「動画」</b>を選ぶ(広告タイプはレスポンシブのまま)。参照表は <code>refs.media[番号].video</code>/<code>.thumb</code>。<b>サムネイル画像が必須</b>で、<b>YDA は動画からサムネを自動生成しない</b>(本番実測)ため、クリエイティブ番号セルの ▾ → サイドバー最下部の<b>「+ 新規登録」</b>で動画を選び、0 秒フレームを抜いて入稿計画に載せる。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
491
515
  <tr><td><b>バナー(静止画・固定サイズ)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★★</td><td>広告行の<b>広告タイプ列で「バナー」</b>を選ぶ(画像は形式列=Card/SQ のまま、<b>レスポンシブと同じ画像プールを共用</b>)。API上は <code>BANNER_AD + mainMediaFormat:IMAGE</code>、画像は <b>operand 直下の <code>mediaId</code></b>。<b>タイトル/説明文/主体者/CTAはAPIフィールド自体が無い</b>(画像に焼き込み)。2026-08-19 に本番実機で add 成功・読み戻し検証済み</td></tr>
492
516
  <tr><td>カルーセル / テキスト</td><td><span class="pill gray">未使用</span></td><td>—</td><td>運用では使わない</td></tr>
493
517
  </tbody></table>
@@ -556,6 +580,21 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
556
580
  <span class="c">// 読み戻すと ad.responsiveAd === null になる</span></div>
557
581
  </div></details>
558
582
  </div>
583
+ <div id="yt-video" style="display:none">
584
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">レスポンシブ動画 — 形式列で「動画」を選ぶ</span><span class="chev"></span></summary>
585
+ <div class="body">
586
+ <p class="lead">画像との違いは <b>素材の参照先</b> と <b>サムネイル</b> の 2 つだけ。広告タイプは「レスポンシブ」のまま、<b>形式列で「動画」</b>を選ぶ(API 上は <code>RESPONSIVE_AD + mainMediaFormat:VIDEO</code>)。タイトル・説明文・主体者・CTA は画像のレスポンシブと同じものを使う。</p>
587
+ <div class="callout"><b>サムネイル画像は必ず要る。</b> Yahoo は動画からサムネイルを自動生成しない(本番実測で確認済み)。サムネイルが無いと広告の作成そのものが失敗するので、プランナーが <b>動画の 0 秒フレームを抜いて</b>一緒に入稿する。</div>
588
+ <table class="g"><thead><tr><th>項目</th><th>内容</th></tr></thead><tbody>
589
+ <tr><td>参照表</td><td><code>refs.media[番号].video</code>(動画 mediaID)と <code>.thumb</code>(サムネ mediaID)。画像の <code>.card</code>/<code>.sq</code> とは別枠で、<b>同じ管理番号に画像と動画がぶら下がる</b></td></tr>
590
+ <tr><td>未登録素材</td><td>広告行の<b>「動画パス」「サムネパス」</b>に計画JSON基準の相対パスを入れる。本実行の直前に CLI が <code>/yda/upload-video</code> へ上げて mediaID に解決する(dry-run では上げない)</td></tr>
591
+ <tr><td><b>動画の取り込み</b></td><td>形式=動画 の行でクリエイティブ番号セルの ▾ を押し、サイドバー最下部の <b>「+ 新規登録」</b>を押す。モーダルで <b>① 保存フォルダ(<code>入稿計画/</code>)を選ぶ → ② 動画を選ぶ</b> の順に進めると、<b>動画パスと 0 秒サムネの両方が自動で入る</b>(素材は入稿計画フォルダの <code>素材/</code> に置かれる。保存フォルダは次回から自動でつながる)。<b>審査でサムネだけ落ちたときは、抽出位置(秒)を変えて取り直す</b></td></tr>
592
+ <tr><td>クリエイティブ名</td><td>形式記号は <code>v</code>(Card は <code>c</code>/SQ は <code>s</code>)。例 <code>957v-45L60-jv_Cac_148</code></td></tr>
593
+ <tr><td>できない組み合わせ</td><td><b>広告タイプ=バナー × 形式=動画</b>。入稿計画の書き出し前にエラーで止まる</td></tr>
594
+ </tbody></table>
595
+ <div class="callout">素材の置き場所は<b>入稿計画フォルダ配下の <code>素材/</code></b>。接続フォルダの外にある動画を選んだ場合はそこへコピーする(パスを自動で入れられるようにするため)。</div>
596
+ </div></details>
597
+ </div>
559
598
  <div id="yt-param" style="display:none">
560
599
  <details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
561
600
  <div class="body">
@@ -617,7 +656,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
617
656
  <li><b>プラン作成</b>:プランビューは「戦略メモ → 案件設定 → シート/ツリー切替」の順。シート選択時はさらに広告/広告グループ/キャンペーンのタブで編集する。AI が生成した state JSON は「JSON読込」で取り込む(旧v1形式も自動マイグレーション)。</li>
618
657
  <li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode未払出)が緑になるまで修正。バナーは広告行の「広告タイプ」列で「バナー」を選ぶ(タイトル/説明文は無効化される。画像はレスポンシブと同じ Card/SQ を使う)。</li>
619
658
  <li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
620
- <li><b>保存フォルダ接続(初回に1回だけ)</b>:履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
659
+ <li><b>保存フォルダ接続(初回に1回だけ)</b>:素材の「+ 新規登録」のモーダル、または履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと(<code>入稿計画/</code> 以外のフォルダは接続できない)、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
621
660
  <li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
622
661
  <li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・配信開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
623
662
  <li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file &lt;入稿計画&gt;</code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
@@ -802,6 +841,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
802
841
  <div class="toolbar2">
803
842
  <button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
804
843
  <button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
844
+
805
845
  <span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
806
846
  </div>
807
847
  <!-- シートタブと表示フィルタは同じ行に並べ、表の上辺に接合する (フォルダタブ風)。
@@ -878,7 +918,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
878
918
  (cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
879
919
  手で書き換えて放置するとテストが落ちる)。
880
920
  利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
881
- const APP_VERSION = '0.11.0'; /* version:sync */
921
+ const APP_VERSION = '0.13.0'; /* version:sync */
882
922
  /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
883
923
  同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
884
924
  固有名詞はハードコードせず、すべて外部JSON由来にする。 */
@@ -1140,6 +1180,11 @@ const EC = {
1140
1180
  CAMPAIGN_NAME_DUPLICATE:'CAMPAIGN_NAME_DUPLICATE' /* 既存CPと同名。Google は同名を拒否する */,
1141
1181
  CAMPAIGN_NAME_DUPLICATE_IN_PLAN:'CAMPAIGN_NAME_DUPLICATE_IN_PLAN' /* 同じ計画の中で新規CP名が重複 */,
1142
1182
  LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
1183
+ /* YDA レスポンシブ動画 固有 */
1184
+ VIDEO_PATH_MISSING:'VIDEO_PATH_MISSING' /* 動画 mediaID 未登録なのに動画パスも未入力 = 上げるものが無い */,
1185
+ THUMB_MISSING:'THUMB_MISSING' /* YDA は動画からサムネを自動生成しないので必ず要る */,
1186
+ VIDEO_PATH_IGNORED:'VIDEO_PATH_IGNORED' /* 動画 mediaID が解決済みなのにローカル動画パスも入力 (warn) */,
1187
+ BANNER_VIDEO_UNSUPPORTED:'BANNER_VIDEO_UNSUPPORTED' /* 広告タイプ=バナー × 形式=動画 は未対応 */,
1143
1188
  /* ByteDance (TikTok) 固有 */
1144
1189
  IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
1145
1190
  IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
@@ -1307,24 +1352,34 @@ const REF_TABLES = {
1307
1352
  {label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
1308
1353
  ],
1309
1354
  optionLabel:v=>String(v==null?'':v) },
1310
- media:{ key:'media', label:'画像', countLabel:'登録画像', editable:true, required:true,
1311
- keyKind:'number', keyLabel:'画像番号', keyWidth:'80px',
1312
- addSub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録',
1313
- emptyMsg:'該当する画像がありません',
1355
+ media:{ key:'media', label:'クリエイティブ', countLabel:'登録クリエイティブ', editable:true, required:true,
1356
+ keyKind:'number', keyLabel:'クリエイティブ番号', keyWidth:'110px',
1357
+ addSub:'クリエイティブ番号→Card/SQ/動画 の mediaID。新規作成した素材をメディアに登録',
1358
+ emptyMsg:'該当するクリエイティブがありません',
1314
1359
  /* YDA のメディアライブラリはアカウントに1つだけ (バナー専用プールは存在しない)。
1315
1360
  バナー広告もここの card / sq をそのまま使う。 */
1361
+ /* video / thumb は **レスポンシブ動画広告** 用 (動画 mediaId とサムネ画像 mediaId の組)。
1362
+ 画像の card / sq と同じ番号を共有する = 1つの管理番号に画像・動画がぶら下がる。 */
1316
1363
  valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
1317
- {k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}],
1364
+ {k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'},
1365
+ {k:'video', label:'動画 mediaID', editLabel:'動画 mediaID'},
1366
+ {k:'thumb', label:'動画サムネ mediaID', editLabel:'サムネ mediaID'}],
1318
1367
  keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
1319
1368
  columns:[
1320
1369
  {label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
1321
1370
  {label:'SQ mediaID (1080)', cls:'mono', cell:(no,v)=>esc(v&&v.sq||'—')},
1371
+ {label:'動画 mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
1372
+ {label:'サムネ mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.thumb||'—')},
1373
+ /* 番号が動画として登録されているなら動画のファイル名を出す (画像の固定名を見せない) */
1322
1374
  {label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
1323
- cell:no=>esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
1375
+ cell:(no,v)=>(v&&v.video)
1376
+ ? esc(no)+'.mp4'+(v.thumb?(' / '+esc(no)+'-thumb.jpg'):'')
1377
+ : esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
1324
1378
  ],
1325
- optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':''].filter(Boolean).join('/'),
1379
+ optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':'', v&&v.video?'動画':''].filter(Boolean).join('/'),
1326
1380
  /* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
1327
- drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—') },
1381
+ drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—')
1382
+ +(v&&v.video?(' / 動画 '+esc(v.video)):'') },
1328
1383
  adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
1329
1384
  keyKind:'string', keyLabel:'広告グループID', keyWidth:'110px',
1330
1385
  emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
@@ -1434,9 +1489,14 @@ PLATFORMS.yda = {
1434
1489
  (アップロードは base64 のみ、広告作成は mediaId のみ)。
1435
1490
  → **バナーもレスポンシブも同じ画像プール (refs.media[番号].card / .sq) を共用する**。
1436
1491
  広告タイプ (RESPONSIVE_AD / BANNER_AD) は別の軸で、広告行の「広告タイプ」列 (ad.adKind) が持つ。 */
1492
+ /* 動画 (VIDEO) は画像と同じ「形式」軸に並ぶ第3の値。広告タイプ (レスポンシブ/バナー) とは直交し、
1493
+ レスポンシブ動画 = RESPONSIVE_AD + mainMediaFormat:VIDEO になる。
1494
+ 動画は Card/SQ のようなサイズ区分を持たない (1本の動画 mediaId + サムネ画像 mediaId の組)。 */
1437
1495
  mediaFormats:[
1438
1496
  {id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
1439
- {id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
1497
+ {id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'},
1498
+ {id:'動画', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画 mediaID',
1499
+ thumbKey:'thumb'}
1440
1500
  ],
1441
1501
  /* 広告タイプがバナー (BANNER_AD) か。**サイズ(形式)ではなく ad.adKind で決まる**。
1442
1502
  (タイトル/説明文セルの無効化・広告名・入稿ボディのテキスト系省略の判定はすべてここを見る。
@@ -1445,6 +1505,9 @@ PLATFORMS.yda = {
1445
1505
  adKind:' バナー ' のような値で adType だけ BANNER_AD になり、
1446
1506
  テキスト系フィールドが入稿計画JSONに混入する。 */
1447
1507
  isBanner(ad){ return PLATFORMS.yda.mappers.adType((ad&&ad.adKind), PLATFORMS.yda)==='BANNER_AD'; },
1508
+ /* 形式=動画 か。**広告タイプ (adKind) ではなく形式 (format) の軸**。
1509
+ 列の有効/無効・参照するメディアキー・入稿ボディの mediaType はすべてここを見る。 */
1510
+ isVideo(ad){ return PLATFORMS.yda.fmtOf(ad).apiMediaKind==='VIDEO'; },
1448
1511
  fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
1449
1512
 
1450
1513
  /* ---- 参照テーブル (S6 の REF_TABLES) ---- */
@@ -1557,7 +1620,8 @@ PLATFORMS.yda = {
1557
1620
  },
1558
1621
  /* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
1559
1622
  /* adKind = 広告タイプ軸 (レスポンシブ / バナー)。format はサイズ軸 (Card / SQ) で直交する */
1560
- ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'', adName:'', desc2:''},
1623
+ ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'',
1624
+ videoPath:'', thumbnailPath:'', adName:'', desc2:''},
1561
1625
  /* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
1562
1626
  campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
1563
1627
  freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
@@ -1621,7 +1685,7 @@ PLATFORMS.yda = {
1621
1685
  }
1622
1686
  },
1623
1687
  /* 「クリエイティブをクリア」対象フィールド */
1624
- creativeFields:['imageNo','titleNo','descNo','imagePath'],
1688
+ creativeFields:['imageNo','titleNo','descNo','imagePath','videoPath','thumbnailPath'],
1625
1689
  /* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
1626
1690
  C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
1627
1691
  notSubmittedFields:['tg_region','tg_searchKw'],
@@ -1635,7 +1699,8 @@ PLATFORMS.yda = {
1635
1699
  + '<span class="chip">'+esc(c.biddingStrategy||p.biddingStrategyDefault)+' <b>'+esc((c.tcpa??p.tcpaDefault))+'</b></span>';
1636
1700
  },
1637
1701
  groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
1638
- adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>'; },
1702
+ /* 形式記号は mediaFormats.crSuffix が正 (c / s / v)。format の1文字目を取ると「動画」が「動」になる */
1703
+ adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+esc(PLATFORMS.yda.fmtOf(a).crSuffix)+'</span>'; },
1639
1704
  adSub(a, r){ return r.isBanner ? 'バナー' : esc(a.titleNo)+'L'+esc(a.descNo); },
1640
1705
  /* バナーにはタイトルが存在しない。シート側は「—」で無効化しているのに
1641
1706
  ツリーが「(未入力)」と出すと「埋めるべき欄」に見えるので別表記にする。 */
@@ -1652,11 +1717,20 @@ PLATFORMS.yda = {
1652
1717
  const autoAdName=P.naming.adName(ad, group, crName, p);
1653
1718
  const adName=String(ad.adName||'').trim() || autoAdName;
1654
1719
  const me = refs.media[String(ad.imageNo)];
1655
- const mediaId = me ? me[fmt.mediaKey] : undefined;
1720
+ /* 参照表の編集モーダル (refEdit) は空欄を **空文字で保存する** ので、'' も未登録として扱う。
1721
+ undefined だけを見ていると ID が空のまま「登録済み」と判定され、警告も出ずに
1722
+ mediaIds:[''] の計画が書き出されて dry-run も通る。YDA には自動ロールバックが無いので、
1723
+ 本実行では CP と広告グループだけが残る。Meta の resolve と同じ判定に揃えている。 */
1724
+ const mediaId = (me ? me[fmt.mediaKey] : undefined) || undefined;
1656
1725
  /* バナー広告 (BANNER_AD) はタイトル/説明文のAPI項目が存在しない (画像に焼き込む運用) ので、
1657
1726
  参照も検証もしない。セル自体も列記述子の col.disabled で無効化している。
1658
1727
  判定は「広告タイプ」列 (ad.adKind) — 画像サイズ (Card/SQ) とは独立した軸。 */
1659
1728
  const isBanner = P.isBanner(ad);
1729
+ /* 形式=動画 のときは動画 mediaId (me.video) とサムネ mediaId (me.thumb) の組で解決する。
1730
+ サムネは YDA が自動生成しないので、mediaId かローカルパスのどちらかが必ず要る。 */
1731
+ const isVideo = P.isVideo(ad);
1732
+ // mediaId と同じ理由で '' も未登録として扱う(サムネ未設定のまま入稿されるのを防ぐ)
1733
+ const thumbId = ((isVideo && me) ? me[fmt.thumbKey] : undefined) || undefined;
1660
1734
  const titleText = isBanner ? undefined : refs.titles[ad.titleNo];
1661
1735
  const descText = isBanner ? undefined : (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
1662
1736
  const finalUrl = P.naming.finalUrl(ad, group, adName, p);
@@ -1681,11 +1755,27 @@ PLATFORMS.yda = {
1681
1755
  // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
1682
1756
  /* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
1683
1757
  if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1684
- if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1758
+ if(pendingImage) pushWarn(isVideo?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1759
+ /* 動画 (レスポンシブ動画) の検証。画像とは要求が違う:
1760
+ ・動画 mediaId も動画パスも無ければ **上げるものが無い** → err (TikTok と同じ。画像の warn 止まりとは違う)
1761
+ ・**サムネは必須**。YDA は動画からサムネを自動生成しないので (本番実測)、
1762
+ mediaId かローカルパスのどちらかが無いと広告作成が R0001 で落ちる
1763
+ ・バナー × 動画 は backend の zod が弾く組み合わせ。dry-run より手前で気づけるよう err にする */
1764
+ const videoPath=String(ad.videoPath||'').trim();
1765
+ const thumbnailPath=String(ad.thumbnailPath||'').trim();
1766
+ if(isVideo){
1767
+ if(isBanner) pushErr('バナー×動画は未対応', EC.BANNER_VIDEO_UNSUPPORTED);
1768
+ if(imgNoStr!=='' && pendingImage && videoPath==='') pushErr('動画パス未入力(動画mediaID未登録)', EC.VIDEO_PATH_MISSING);
1769
+ if(!pendingImage && videoPath!=='') pushWarn('動画パス無視(動画mediaIDが解決済み)', EC.VIDEO_PATH_IGNORED);
1770
+ /* サムネのローカルパスは **動画を上げるときにしか送れない** (CLI は upload-video の multipart に
1771
+ 添える形でしか受け取らない)。動画 mediaID が解決済みの行では thumb mediaID が無いと入稿できない。 */
1772
+ if(imgNoStr!=='' && thumbId===undefined && (thumbnailPath==='' || !pendingImage))
1773
+ pushErr(pendingImage?'サムネ未設定(動画広告に必須)':'サムネ mediaID 未登録(動画が登録済みの行ではパスを使えません)', EC.THUMB_MISSING);
1774
+ }
1685
1775
  // C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
1686
1776
  // ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
1687
- const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
1688
- if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
1777
+ const imagePaths=isVideo?[]:String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
1778
+ if(!isVideo && pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
1689
1779
  /* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
1690
1780
  (CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
1691
1781
  黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
@@ -1694,7 +1784,8 @@ PLATFORMS.yda = {
1694
1784
  // F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
1695
1785
  const aid=String(group.adGroupId||'').trim();
1696
1786
  return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind,
1697
- adType:P.mappers.adType(ad.adKind, P), isBanner, imageNo:imgNoStr,
1787
+ adType:P.mappers.adType(ad.adKind, P), isBanner, isVideo, imageNo:imgNoStr,
1788
+ videoPath, thumbnailPath, thumbId:thumbId||'',
1698
1789
  mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
1699
1790
  descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
1700
1791
  },
@@ -1785,7 +1876,9 @@ PLATFORMS.yda = {
1785
1876
  const ad={
1786
1877
  adName:r.adName,
1787
1878
  status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
1788
- mediaType:'IMAGE', // 画像広告 (backend ydaAdSchema IMAGE)。バナーもメイン形式は IMAGE
1879
+ /* 形式 (Card/SQ/動画) 由来。画像広告は IMAGE (バナーもメイン形式は IMAGE)、
1880
+ レスポンシブ動画は VIDEO (backend ydaAdSchema mediaType)。 */
1881
+ mediaType:r.mediaKind,
1789
1882
  adType:r.adType,
1790
1883
  finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
1791
1884
  };
@@ -1798,6 +1891,17 @@ PLATFORMS.yda = {
1798
1891
  ad.principal=p.principal||''; // E-1: 主体者はアカウントJSON由来 (st.project.principal)
1799
1892
  ad.buttonText=P.mappers.cta(p.cta, P); // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
1800
1893
  }
1894
+ /* レスポンシブ動画 (mediaType:VIDEO)。画像の mediaIds ではなく videoMediaId + thumbnailMediaId を送る。
1895
+ 未登録なら videoPath / thumbnailPath を残し、CLI が本実行の直前に /yda/upload-video へ上げて
1896
+ 両IDに解決する (dry-run では上げない)。**IDとパスを同時に載せない** — 載せると CLI が
1897
+ 余計にアップロードして、広告には古いIDが使われる (画像側で踏んだのと同じ罠)。 */
1898
+ if(r.isVideo){
1899
+ if(r.pendingImage){ ad.videoPath=r.videoPath; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; }
1900
+ else ad.videoMediaId=String(r.mediaId);
1901
+ if(r.thumbId) ad.thumbnailMediaId=String(r.thumbId);
1902
+ else if(r.thumbnailPath) ad.thumbnailPath=r.thumbnailPath;
1903
+ return ad;
1904
+ }
1801
1905
  if(r.pendingImage){
1802
1906
  // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
1803
1907
  // C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
@@ -1907,7 +2011,8 @@ PLATFORMS.yda = {
1907
2011
  }
1908
2012
  },
1909
2013
  docTabs:[{id:'overview',label:'概要'},{id:'resp',label:'レスポンシブ'},
1910
- {id:'banner',label:'バナー'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
2014
+ {id:'banner',label:'バナー'},{id:'video',label:'動画'},
2015
+ {id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
1911
2016
  };
1912
2017
 
1913
2018
  /* 宣言に載せない旧テーブル。resolveGroupIds/maxParamNum が参照中のため S11 の撤去まで温存する。 */
@@ -2085,6 +2190,7 @@ function initialState(){
2085
2190
  campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
2086
2191
  migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
2087
2192
  migratePlatformValues(campaigns); // 媒体側の旧値を現行の語彙へ
2193
+ project.localMedia=samplePlanLocalMedia();
2088
2194
  }
2089
2195
  }
2090
2196
  return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
@@ -2603,7 +2709,9 @@ function refEdit(tableKey,no){
2603
2709
  value:single?cur:((cur||{})[fd.k]||'')})),
2604
2710
  onOk:v=>{ refCommit(()=>{
2605
2711
  if(single){ store[no]=v[T.valueFields[0].k]; return; }
2606
- const e={}; T.valueFields.forEach(fd=>{ e[fd.k]=String(v[fd.k]).trim(); }); store[no]=e;
2712
+ /* 空欄は **キーごと落とす**。空文字を入れると resolve が「登録済み」と誤判定し、
2713
+ ID が空のまま入稿されて広告作成で落ちる (作りかけの CP が残る)。 */
2714
+ const e={}; T.valueFields.forEach(fd=>{ const sv=String(v[fd.k]).trim(); if(sv!=='') e[fd.k]=sv; }); store[no]=e;
2607
2715
  }, '更新しました'); }});
2608
2716
  }
2609
2717
  function refDel(tableKey,no){
@@ -2729,10 +2837,11 @@ const COLS = {
2729
2837
  ads:[
2730
2838
  /* 形式 = **画像のサイズ軸** (Card=1200x628 / SQ=1080x1080)。参照表 refs.media[番号].card / .sq を引く。
2731
2839
  広告タイプ (レスポンシブ/バナー) とは直交する軸で、バナーも同じ画像プールを使う。 */
2732
- {field:'format', label:'形式', kind:'optpick', options:['Card','SQ'], width:'72px',
2733
- title:'画像のサイズ。参照表の Card / SQ mediaID を引きます(バナーも同じ画像プールを使います)',
2734
- coerce:v=>{ const s=String(v).trim().toUpperCase();
2735
- if(s==='SQ') return 'SQ';
2840
+ {field:'format', label:'形式', kind:'optpick', options:['Card','SQ','動画'], width:'72px',
2841
+ title:'素材の種類。Card / SQ は画像のサイズ(バナーも同じ画像プールを使います)、動画はレスポンシブ動画広告',
2842
+ coerce:v=>{ const s=String(v).trim(), u=s.toUpperCase();
2843
+ if(s==='動画'||u==='VIDEO') return '動画';
2844
+ if(u==='SQ') return 'SQ';
2736
2845
  return 'Card'; }},
2737
2846
  /* 広告タイプ = **RESPONSIVE_AD / BANNER_AD の軸**。行単位で切り替えるので同一キャンペーン内に混在できる。
2738
2847
  バナー行はタイトル/説明文のセルが col.disabled で無効化される (PLATFORMS.yda.isBanner)。 */
@@ -2741,9 +2850,9 @@ const COLS = {
2741
2850
  get:row=>row.adKind||'レスポンシブ',
2742
2851
  coerce:v=>{ const s=String(v).trim();
2743
2852
  return (s==='バナー'||s.toUpperCase()==='BANNER'||s.toUpperCase()==='BANNER_AD')?'バナー':'レスポンシブ'; }},
2744
- {field:'imageNo', label:'画像番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
2853
+ {field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
2745
2854
  width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
2746
- ghostKey:'media', ddTitle:'登録画像から選択', ghostTitle:r=>'メディアID '+r.mediaId,
2855
+ ghostKey:'media', ddTitle:'登録クリエイティブから選択', ghostTitle:r=>'メディアID '+r.mediaId,
2747
2856
  ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
2748
2857
  if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>';
2749
2858
  if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
@@ -2784,7 +2893,25 @@ const COLS = {
2784
2893
  disabledTitle:'バナー広告(BANNER_AD)には説明文のAPI項目がありません(画像に焼き込み)'}),
2785
2894
  /* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
2786
2895
  {field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
2896
+ disabled:it=>it.kind==='a' && PLATFORMS.yda.isVideo(it.a),
2897
+ disabledTitle:'形式=動画の行では画像パスを使いません(動画パス / サムネパスを使います)',
2787
2898
  title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
2899
+ /* 形式=動画 のローカル素材パス。**クリエイティブ番号の右サイドバー「+ 新規登録」から選ぶと、
2900
+ 動画パスと 0 秒サムネ (自動抽出→接続フォルダへ書き出し) の両方が自動で入る**。手入力も可。
2901
+ 本実行の直前に CLI が /yda/upload-video へ上げて videoMediaId / thumbnailMediaId に解決する
2902
+ (dry-run では上げない)。動画 mediaID が参照表で解決済みの行では入稿JSONに載らない。 */
2903
+ {field:'videoPath', label:'動画パス', kind:'mono', width:'150px',
2904
+ disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
2905
+ disabledTitle:'形式が「動画」の行だけで使います',
2906
+ errCodes:[EC.VIDEO_PATH_MISSING],
2907
+ title:'ローカル動画の絶対 / ~/… / 計画JSON基準の相対パス。※動画 mediaID が参照表で解決できる行では入稿JSONに載りません'},
2908
+ /* 動画広告のサムネイル画像。YDA は **動画からサムネを自動生成しない** (本番実測) ので、
2909
+ 0 秒フレームを抽出した画像を必ず一緒に入稿する必要がある。 */
2910
+ {field:'thumbnailPath', label:'サムネパス', kind:'mono', width:'150px',
2911
+ disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
2912
+ disabledTitle:'形式が「動画」の行だけで使います',
2913
+ errCodes:[EC.THUMB_MISSING],
2914
+ title:'動画サムネイル画像のパス。クリエイティブ番号のサイドバー「+ 新規登録」で動画を選ぶと、0 秒フレームを自動抽出して埋まります(審査で落ちたときだけ差し替える)'},
2788
2915
  /* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
2789
2916
  {field:'adName', label:'広告名', kind:'mono', width:'170px',
2790
2917
  title:'空欄なら自動生成(レスポンシブ: {画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode} / バナー: {画像番号}{c|s}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
@@ -5161,7 +5288,9 @@ function applySequentialParamCodes(){
5161
5288
  const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
5162
5289
  if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
5163
5290
  const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
5164
- mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0'); }); });
5291
+ mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0');
5292
+ /* この計画で新規登録した番号になったら、その素材のパスも入れる */
5293
+ if(sc.field==='imageNo') applyLocalMediaPaths(it, it.a[sc.field]); }); });
5165
5294
  toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
5166
5295
  }
5167
5296
  }
@@ -5451,18 +5580,101 @@ function renderDrawer(){
5451
5580
  const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
5452
5581
  const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
5453
5582
  const single=!!(T.valueFields&&T.valueFields.length===1);
5454
- titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)') : ((T.countLabel||T.label||'')+' 一覧');
5583
+ /* 素材の一覧は **その行の形式に合うものだけ** 出す。動画行に画像しか持たない番号を
5584
+ 並べても選べないため。形式ごとの参照キー (YDA=card/sq/video / Meta・Google=image/video
5585
+ / TikTok=video) は媒体定義の mediaFormats が持つので、コアは mediaKey だけを見る。 */
5586
+ const isMedia = rkey==='media';
5587
+ const fmt = (isMedia && fa) ? pf().fmtOf(fa.a) : null;
5588
+ const hasFmt = v => !fmt || !fmt.mediaKey || String((v||{})[fmt.mediaKey]||'').trim()!=='';
5589
+ titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)')
5590
+ : ((T.countLabel||T.label||'')+' 一覧'+(fmt?('('+fmt.id+')'):''));
5455
5591
  let html='';
5456
5592
  refSortedKeys({keyKind:'number'},store).forEach(no=>{
5457
5593
  const v=store[no];
5458
5594
  if(T.dropEmpty && String(v==null?'':v).trim()==='') return;
5595
+ if(isMedia && !hasFmt(v)) return;
5459
5596
  if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
5460
5597
  const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
5461
5598
  html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
5462
5599
  +(single?'':thumbImgHtml(no))+'<span class="ddno">'+esc(no)+'</span>'
5463
5600
  +(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
5464
5601
  });
5465
- listEl.innerHTML=html||'<div class="empty" style="padding:24px">該当なし</div>';
5602
+ /* この計画で新しく登録した素材。**まだ媒体に上がっていないので mediaID が無く、refs には入れない**
5603
+ (空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
5604
+ 一覧には出して、2行目以降でも同じ番号を選べるようにする。 */
5605
+ if(isMedia) pendingMediaNos().forEach(no=>{
5606
+ if(q && !String(no).includes(q)) return;
5607
+ const lm=((st.project&&st.project.localMedia)||{})[no];
5608
+ /* 種類 (動画 / 画像) が行と違う素材は出さない */
5609
+ if(lm && fmt && ((fmt.apiMediaKind==='VIDEO') !== !!lm.video)) return;
5610
+ const thumb=lm ? (lm.thumb||lm.image) : '';
5611
+ html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
5612
+ +(thumb?'<img class="ddthumb" data-localthumb="'+esc(thumb)+'" alt="">':'')
5613
+ +'<span class="ddno">'+esc(no)+'</span>'
5614
+ +'<span class="idpill warn">🆕未登録</span>'
5615
+ +'<span class="ddtx">'+(lm&&lm.file?esc(lm.file)+' ':'')+'この計画で追加(入稿時にアップロードします)</span></div>';
5616
+ });
5617
+ listEl.innerHTML=seqRowHtml()+(html||'<div class="empty" style="padding:24px">該当なし</div>')
5618
+ + ((isMedia && newMediaFields(fmt)) ? newMediaRowHtml() : '');
5619
+ hydrateLocalThumbs(listEl);
5620
+ }
5621
+
5622
+ /** サイドバー末尾の1行。**素材の置き場所が無いと登録できない**ので、
5623
+ 未接続のときは登録ではなく接続を促す(ヘッダーに接続ボタンは無く、履歴ビューの奥にある)。 */
5624
+ /** 複数行を選んでいるとき、サイドバーの先頭に「連番」を出す (ツールバーの 🔢 連番 と同じ処理) */
5625
+ function seqRowHtml(){
5626
+ const btn=document.getElementById('btnSeqParam');
5627
+ if(!btn || btn.style.display==='none') return '';
5628
+ const sc=seqColInfo(); if(!sc) return '';
5629
+ return '<div class="ddrow ddseq" data-seq="1"><span class="ddno">🔢</span>'
5630
+ +'<span class="ddtx">選択した行の'+esc(sc.label)+'を連番にする<span class="sm" style="color:var(--faint)"> 先頭のセルの番号から</span></span></div>';
5631
+ }
5632
+ function newMediaRowHtml(){
5633
+ if(!fsSupported)
5634
+ return '<div class="ddrow ddnew" style="cursor:default;color:var(--faint)">'
5635
+ + '<span class="ddno">—</span><span class="ddtx">このブラウザでは素材を登録できません'
5636
+ + '(Chrome 系で開いてください)</span></div>';
5637
+ /* 保存フォルダの接続は「+ 新規登録」のモーダル (openNewMediaWizard) の中で案内する */
5638
+ return '<div class="ddrow ddnew" data-newmedia="1"><span class="ddno">+</span>'
5639
+ + '<span class="ddtx">新規登録(手元のファイルから)'
5640
+ + (dirHandle ? '<span class="sm" style="color:var(--faint)"> 保存先: '+esc(dirHandle.name)+'/</span>' : '')
5641
+ + '</span></div>';
5642
+ }
5643
+
5644
+ /** 新規登録で使う**パス列**。素材を置いてもパスを入れる先が無い媒体・形式では登録させない
5645
+ (入れ先が無いまま番号だけ入ると、入稿時にアップロード元が見つからない)。
5646
+ 列は field 名で引く — 媒体ごとに名前が違う (YDA/Meta=thumbnailPath / TikTok=coverImagePath)。 */
5647
+ function newMediaFields(fmt){
5648
+ if(!fmt) return null;
5649
+ const has=f=>colDefs('ads').some(c=>c.field===f);
5650
+ if(fmt.apiMediaKind==='VIDEO'){
5651
+ if(!has('videoPath')) return null;
5652
+ const thumb=has('thumbnailPath')?'thumbnailPath':(has('coverImagePath')?'coverImagePath':'');
5653
+ return thumb ? {video:'videoPath', thumb} : null; // サムネの置き場が無ければ登録させない
5654
+ }
5655
+ return has('imagePath') ? {image:'imagePath'} : null;
5656
+ }
5657
+
5658
+ /** 計画内で使われているが参照表に無い管理番号 = 新規登録してまだ上げていない素材。 */
5659
+ function pendingMediaNos(){
5660
+ const store=st.refs.media||{}, seen=new Set();
5661
+ st.campaigns.forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
5662
+ const no=String(a.imageNo||'').trim();
5663
+ if(no!=='' && !store[no]) seen.add(no);
5664
+ })));
5665
+ Object.keys((st.project&&st.project.localMedia)||{}).forEach(no=>{ if(!store[no]) seen.add(no); });
5666
+ return [...seen].sort((a,b)=>Number(a)-Number(b));
5667
+ }
5668
+
5669
+ /** 空き番号 = 参照表と計画内で使われている番号の最大値 +1。
5670
+ ⚠ `Math.max(...nums)` は使わない — 引数がスタックに積まれるので、
5671
+ 参照表が大きいアカウントで `Maximum call stack size exceeded` になる。 */
5672
+ function nextMediaNo(){
5673
+ let max=0;
5674
+ Object.keys(st.refs.media||{}).concat(pendingMediaNos()).forEach(k=>{
5675
+ const n=Number(k); if(Number.isFinite(n) && n>max) max=n;
5676
+ });
5677
+ return String(max+1);
5466
5678
  }
5467
5679
  /* ============ 素材サムネ (#157) ============
5468
5680
  媒体側の画像URLはどれも一時URL・CORS 制限つきで、プランナーから直接は表示できない。
@@ -5541,8 +5753,10 @@ function drawerPick(no){
5541
5753
  const compat = compatColsFor(drawerTarget.type, sheetTab);
5542
5754
  const rg=selRange(); let applied=0;
5543
5755
  mutate(()=>{
5544
- if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); applied++; } } } }
5545
- if(applied===0){ setField(drawerTarget.u,f,num); applied=1; }
5756
+ /* この計画で新規登録した番号なら、番号に紐づくローカル素材のパスも一緒に入れる */
5757
+ const withPaths=item=>{ if(drawerTarget.type==='media') applyLocalMediaPaths(item, no); };
5758
+ if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); withPaths(item); applied++; } } } }
5759
+ if(applied===0){ setField(drawerTarget.u,f,num); const fa=findAd(drawerTarget.u); if(fa) withPaths({kind:'a',a:fa.a,g:fa.g,c:fa.c}); applied=1; }
5546
5760
  });
5547
5761
  toast('番号 '+no+' を'+applied+'セルに入力');
5548
5762
  closeDrawer();
@@ -5842,9 +6056,16 @@ function openModal(cfg){
5842
6056
  const getVals=()=>{ const o={}; (cfg.fields||[]).forEach(f=>{ const el=document.getElementById(fid(f.key)); if(!el) return; o[f.key]= f.type==='check'? el.checked : el.value; }); return o; };
5843
6057
  const setVal=(k,val)=>{ const el=document.getElementById(fid(k)); if(el) el.value=val; };
5844
6058
  const close=()=>root.innerHTML='';
5845
- root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
5846
- root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) close(); });
5847
- root.querySelector('[data-mo="ok"]').addEventListener('click',()=>{ const v=getVals(); close(); if(cfg.onOk) cfg.onOk(v); });
6059
+ /* onCancel: 閉じたときの後片付け (プレビュー用の ObjectURL の解放など) */
6060
+ const bail=()=>{ close(); if(cfg.onCancel) cfg.onCancel(); };
6061
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',bail);
6062
+ root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) bail(); });
6063
+ root.querySelector('[data-mo="ok"]').addEventListener('click',()=>{
6064
+ const v=getVals();
6065
+ /* validate: 空文字なら通す / メッセージを返したら **閉じずに** 差し戻す */
6066
+ if(cfg.validate){ const msg=cfg.validate(v); if(msg){ toast(msg); return; } }
6067
+ close(); if(cfg.onOk) cfg.onOk(v);
6068
+ });
5848
6069
  if(cfg.onMount) cfg.onMount(root, getVals, setVal);
5849
6070
  const first=root.querySelector('input,select,textarea'); if(first) first.focus();
5850
6071
  }
@@ -5909,13 +6130,66 @@ let _idbPromise=null;
5909
6130
  function idb(){ if(!_idbPromise) _idbPromise=idbOpen(IDB_NAME).then(async db=>{ await migrateLegacyIdb(db); return db; }); return _idbPromise; }
5910
6131
  async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
5911
6132
  async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
5912
- async function connectFolder(){
5913
- if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です(保存はダウンロード動作)'); return; }
6133
+ /* ---- 入稿計画フォルダかどうかの判定 ----
6134
+ このフォルダは 素材/ の置き場所であり、「保存して入稿」で入稿計画ファイルを書き出す先でもある。
6135
+ 保存後の入稿手順は `入稿計画/<file>` にある前提で CLI を案内し、CLI は計画ファイルの場所を基準に
6136
+ 素材/ を探すので、**別のフォルダに接続すると本実行のアップロードまで誰も気づけない**。
6137
+ そのため 入稿計画/ 以外には接続させない。
6138
+ 判定: 目印ファイル (PLANS_MARKER) があるか、フォルダ名が 入稿計画 か。
6139
+ 名前で通すのは目印の無い旧配布物のためで、そのときは目印を足して以後は目印で判定する。 */
6140
+ const PLANS_DIR_NAME='入稿計画';
6141
+ const PLANS_MARKER='.ad-planner-plans';
6142
+ async function checkPlansDir(h){
6143
+ try{ await h.getFileHandle(PLANS_MARKER); return {ok:true}; }catch(e){}
6144
+ if(h.name===PLANS_DIR_NAME){
6145
+ try{ const fh=await h.getFileHandle(PLANS_MARKER,{create:true});
6146
+ await writeToHandle(fh,'ad-planner の入稿計画フォルダの目印です。消さないでください。\n'); }catch(e){}
6147
+ return {ok:true};
6148
+ }
6149
+ return {ok:false, reason:'選んだフォルダ「'+h.name+'」は入稿計画フォルダではありません。'
6150
+ +(plannerEnv.plansDir ? '下のパスのフォルダを選んでください' : 'ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください')};
6151
+ }
6152
+ /* ブラウザはフォルダの絶対パスを知る手段が無いので、配信元の adops.sh planner が書いた
6153
+ .planner-env.json から 入稿計画/ の場所を読んで案内に使う。別の方法で開いたときは無い。 */
6154
+ /* 読むのは「+ 新規登録」を押したときだけ。起動時に読むと、ファイルの無い環境 (CI・adops.sh を通さない開き方)
6155
+ で 404 がコンソールエラーとして出て、「起動時に JS エラーが出ない」系のテストが一斉に落ちる */
6156
+ let plannerEnv={}, plannerEnvLoaded=false;
6157
+ async function loadPlannerEnv(){
6158
+ if(plannerEnvLoaded) return; plannerEnvLoaded=true;
6159
+ try{ const r=await fetch('.planner-env.json',{cache:'no-store'}); if(r.ok) plannerEnv=(await r.json())||{}; }catch(e){}
6160
+ }
6161
+ async function adoptPlansDir(h){
6162
+ dirHandle=h; pendingDirHandle=null; await idbSet('dir',h); renderHist(); updateSubmitJsonBtn();
6163
+ }
6164
+ /** 入稿計画フォルダを選んで接続する。**クリック起点で呼ぶ** (ピッカーと権限の再要求はユーザー操作が要る)。
6165
+ 入稿計画フォルダでなければ接続しない。fresh=true なら記憶中のフォルダの再許可を飛ばして選び直させる。
6166
+ @returns {{ok:boolean, name?:string, reconnected?:boolean, aborted?:boolean, reason?:string}} */
6167
+ async function pickPlansFolder(fresh){
6168
+ if(!fsSupported) return {ok:false, reason:'このブラウザはフォルダ連携に未対応です(Chrome 系で開いてください)'};
5914
6169
  try{
5915
- if(pendingDirHandle){ const p=await pendingDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ dirHandle=pendingDirHandle; pendingDirHandle=null; renderHist(); toast('保存フォルダに再接続しました'); return; } }
5916
- dirHandle=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
5917
- pendingDirHandle=null; await idbSet('dir',dirHandle); renderHist(); updateSubmitJsonBtn(); toast('保存フォルダに接続しました: '+dirHandle.name);
5918
- }catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
6170
+ if(pendingDirHandle && !fresh){
6171
+ const h=pendingDirHandle;
6172
+ const p=await h.requestPermission({mode:'readwrite'});
6173
+ if(p==='granted'){
6174
+ const c=await checkPlansDir(h);
6175
+ if(!c.ok){ pendingDirHandle=null; return {ok:false, reason:c.reason}; } // 記憶していたのが別のフォルダ
6176
+ await adoptPlansDir(h); return {ok:true, name:h.name, reconnected:true};
6177
+ }
6178
+ }
6179
+ const h=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
6180
+ const c=await checkPlansDir(h);
6181
+ if(!c.ok) return {ok:false, reason:c.reason};
6182
+ await adoptPlansDir(h); return {ok:true, name:h.name};
6183
+ }catch(e){
6184
+ if(e && e.name==='AbortError') return {ok:false, aborted:true};
6185
+ return {ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)};
6186
+ }
6187
+ }
6188
+ /** 履歴ビューの「📁 保存フォルダに接続」 */
6189
+ async function connectFolder(){
6190
+ const r=await pickPlansFolder(false);
6191
+ if(r.ok) toast(r.reconnected?'保存フォルダに再接続しました':'保存フォルダに接続しました: '+r.name);
6192
+ else if(!r.aborted) toast(r.reason, {sticky:true});
5919
6193
  }
5920
6194
  /* 入稿計画フォルダ未接続なら書き出しボタンを「接続」ボタンに切替(接続後は書き出し) */
5921
6195
  function updateSubmitJsonBtn(){ /* ボタンは『サンプルプラン更新』に用途変更したためラベル切替は無効化 */ }
@@ -5923,8 +6197,14 @@ async function restoreFolder(){
5923
6197
  if(!fsSupported) return;
5924
6198
  try{ const h=await idbGet('dir'); if(!h) return;
5925
6199
  const p=await h.queryPermission({mode:'readwrite'});
5926
- if(p==='granted'){ dirHandle=h; toast('入稿計画フォルダ('+h.name+'/)に自動接続しました'); }
5927
- else pendingDirHandle=h; // 権限失効時はボタン押下(ユーザー操作)で1クリック再要求
6200
+ if(p==='granted'){
6201
+ /* 記憶しているのが別のフォルダなら自動接続しない (すでに違うフォルダを選んでいた人のため) */
6202
+ const c=await checkPlansDir(h);
6203
+ if(c.ok){ dirHandle=h; toast('入稿計画フォルダ('+h.name+'/)に自動接続しました'); }
6204
+ else toast('記憶していた保存フォルダ「'+h.name+'」は入稿計画フォルダではないため、接続しませんでした。'
6205
+ +'素材の「+ 新規登録」から選び直してください', {sticky:true});
6206
+ }
6207
+ else pendingDirHandle=h; // 権限失効時はボタン押下(ユーザー操作)で1クリック再要求。判定は再許可のあと
5928
6208
  }catch(e){}
5929
6209
  if(activeView==='hist') renderHist();
5930
6210
  }
@@ -5934,6 +6214,331 @@ async function writePlanFile(name, text){
5934
6214
  catch(e){ toast('フォルダ書き込み失敗: '+e.message+'(ダウンロードに切替)'); return false; }
5935
6215
  }
5936
6216
 
6217
+ /* ============================================================
6218
+ レスポンシブ動画の取り込み (#324)
6219
+ ------------------------------------------------------------
6220
+ ブラウザは列に書いた「パス文字列」だけではローカル動画を読めないので、ファイルピッカーで
6221
+ 選んでもらう。選んだ動画から **0 秒フレームを canvas で抜いてサムネにする**。
6222
+ YDA は動画からサムネを自動生成しないので (本番実測。connectors/yahooSubmission.ts uploadVideo
6223
+ のコメント参照)、入稿には必ずサムネ画像が要る。審査でサムネだけ落ちることがあるため、
6224
+ 抽出位置(秒)を変えてやり直せるようにしている。
6225
+
6226
+ 素材は入稿計画フォルダ配下の 素材/ に置き、列には **計画JSON基準の相対パス** を入れる
6227
+ (CLI の resolveLocalMediaPath が plan ファイルのディレクトリを基準に解決する)。
6228
+ 接続フォルダの外にある動画はそこへコピーする — パスを自動で入れられないと
6229
+ 「ファイルを選んだら両方埋まる」が成立しないため。
6230
+ ============================================================ */
6231
+ const MEDIA_SUBDIR='素材';
6232
+
6233
+ /** 動画の指定秒のフレームを JPEG Blob で返す。
6234
+ sec=0 は seek せず loadeddata のフレームを使う (currentTime=0 の代入では seeked が
6235
+ 発火しないブラウザがあり、待ち続けて固まるため)。 */
6236
+ function grabVideoFrame(file, sec){
6237
+ return new Promise((resolve, reject)=>{
6238
+ const url=URL.createObjectURL(file);
6239
+ const v=document.createElement('video');
6240
+ v.preload='auto'; v.muted=true; v.playsInline=true;
6241
+ let settled=false;
6242
+ const fail=e=>{ if(settled) return; settled=true; URL.revokeObjectURL(url); reject(e instanceof Error?e:new Error('動画を読み込めません: '+file.name)); };
6243
+ const shoot=()=>{
6244
+ if(settled) return;
6245
+ try{
6246
+ const c=document.createElement('canvas');
6247
+ c.width=v.videoWidth; c.height=v.videoHeight;
6248
+ if(!c.width||!c.height) return fail(new Error('動画のサイズを取得できません: '+file.name));
6249
+ c.getContext('2d').drawImage(v,0,0,c.width,c.height);
6250
+ c.toBlob(b=>{ if(settled) return; settled=true; URL.revokeObjectURL(url);
6251
+ b?resolve({blob:b, width:c.width, height:c.height, duration:v.duration||0}):reject(new Error('サムネを作れません: '+file.name)); },
6252
+ 'image/jpeg', 0.92);
6253
+ }catch(e){ fail(e); }
6254
+ };
6255
+ v.onerror=()=>fail(new Error('動画を読み込めません(対応していない形式かもしれません): '+file.name));
6256
+ v.onseeked=shoot;
6257
+ v.onloadeddata=()=>{
6258
+ const want=Math.max(0, Number(sec)||0);
6259
+ const max=Math.max(0,(v.duration||0)-0.05);
6260
+ const at=Math.min(want, max);
6261
+ if(at<=0) shoot(); else { try{ v.currentTime=at; }catch(e){ fail(e); } }
6262
+ };
6263
+ v.src=url;
6264
+ });
6265
+ }
6266
+
6267
+ /** 入稿計画フォルダ配下 素材/ にファイルを書き、計画JSON基準の相対パスを返す */
6268
+ async function writeMediaFile(name, data){
6269
+ if(!dirHandle) throw new Error('入稿計画フォルダに接続していません(素材の「+ 新規登録」から保存フォルダを選んでください)');
6270
+ const dir=await dirHandle.getDirectoryHandle(MEDIA_SUBDIR,{create:true});
6271
+ const fh=await dir.getFileHandle(name,{create:true});
6272
+ const w=await fh.createWritable(); await w.write(data); await w.close();
6273
+ return MEDIA_SUBDIR+'/'+name;
6274
+ }
6275
+ /** 既に接続フォルダの中にある動画はコピーせず、その相対パスをそのまま使う */
6276
+ async function videoRelPath(handle, file){
6277
+ try{
6278
+ const seg=await dirHandle.resolve(handle);
6279
+ if(seg&&seg.length) return seg.join('/');
6280
+ }catch(e){}
6281
+ return await writeMediaFile(safeFileName(file.name), file);
6282
+ }
6283
+ /** ファイル名に使えない文字を落とす (広告名や日本語はそのまま通す) */
6284
+ function safeFileName(n){ return String(n||'file').replace(/[\/\\:*?"<>|]/g,'_'); }
6285
+
6286
+ /* ---- サイドバーの「+ 新規登録」 ----
6287
+ 画像も動画も**番号セルの右サイドバーから**登録する (ツールバーに別動線を作らない)。
6288
+ 参照表に無い番号を行に入れ、素材は 素材/ へ置いてパス列に入れるだけ。mediaID は
6289
+ 入稿の直前に CLI がアップロードして解決するので、ここでは refs.media に触らない。
6290
+
6291
+ ⚠ showOpenFilePicker / showDirectoryPicker は**ユーザー操作の直後**にしか開けない
6292
+ (await を挟むと失効する。フォルダを選んだ直後に続けてファイル選択を開くと失敗する)。
6293
+ そのため「① 保存フォルダ → ② 素材」をモーダルのボタンで1クリックずつ進めてもらい、
6294
+ 番号の確認はファイルを選び終わってから出す。 */
6295
+ async function openNewMediaWizard(){
6296
+ if(!drawerTarget || !drawerTarget.u){ return; }
6297
+ const target={u:drawerTarget.u, f:drawerTarget.f}; // await の前に確定させる
6298
+ await loadPlannerEnv();
6299
+ /* ダイアログを開いている間に drawer が閉じられると drawerTarget が null になる。
6300
+ **対象の行はここで確定させて持ち回る** (await をまたいで drawerTarget を読まない)。 */
6301
+ const fa=findAd(target.u); if(!fa) return;
6302
+ const fmt=pf().fmtOf(fa.a);
6303
+ const isVideo=fmt && fmt.apiMediaKind==='VIDEO';
6304
+ const kind=isVideo?'動画':'画像';
6305
+ if(!fsSupported){ toast('このブラウザはローカルフォルダ連携に未対応です(Chrome 系で開いてください)'); return; }
6306
+
6307
+ const root=document.getElementById('modalRoot');
6308
+ let error='', busy=false;
6309
+ const close=()=>{ root.innerHTML=''; };
6310
+ /* キャンセルか、入稿計画フォルダを選んで素材まで進むまで閉じない (オーバーレイのクリックでも閉じない) */
6311
+ const render=()=>{
6312
+ const ok=!!dirHandle;
6313
+ const step1 = ok
6314
+ ? '<div class="nmstep done"><div class="nmhead"><span class="nmno">✓</span>保存フォルダ</div>'
6315
+ +'<div class="nmbody"><span class="mono">'+esc(dirHandle.name)+'/</span>'
6316
+ +'<button class="act ghost" data-nm="dirfresh">変更</button></div></div>'
6317
+ : '<div class="nmstep cur"><div class="nmhead"><span class="nmno">1</span>保存フォルダ('+PLANS_DIR_NAME+')を選ぶ'
6318
+ +'<span class="pill">初期設定(初回のみ)</span></div>'
6319
+ +'<div class="nmbody"><div class="hint">最初の1回だけの設定です。次回からは自動でつながります。'
6320
+ +'素材('+MEDIA_SUBDIR+'/)と入稿計画ファイルの保存先になります。</div>'
6321
+ +(plannerEnv.plansDir
6322
+ ? '<div class="nmpath"><span class="mono">'+esc(plannerEnv.plansDir)+'</span>'
6323
+ +'<button class="act ghost" data-nm="copypath">パスをコピー</button></div>'
6324
+ +'<div class="hint">「保存フォルダを選ぶ」で開く画面で <b>Cmd+Shift+G</b>(Windows はアドレス欄)にこのパスを貼り付けて移動し、そのまま「選択」を押してください</div>'
6325
+ : '<div class="hint">ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください</div>')
6326
+ +(pendingDirHandle
6327
+ ? '<button class="act primary" data-nm="dir">前回のフォルダ('+esc(pendingDirHandle.name)+')を再許可する</button>'
6328
+ +'<button class="act ghost" data-nm="dirfresh">別のフォルダを選ぶ</button>'
6329
+ : '<button class="act primary" data-nm="dir">保存フォルダを選ぶ</button>')
6330
+ +'</div></div>';
6331
+ const step2 = '<div class="nmstep '+(ok?'cur':'wait')+'"><div class="nmhead"><span class="nmno">2</span>'+kind+'を選ぶ</div>'
6332
+ +'<div class="nmbody"><div class="hint">'+(isVideo
6333
+ ? '選んだ動画から 0 秒目のフレームをサムネイルとして切り出します(次の画面で確認・変更できます)'
6334
+ : '選んだ画像を '+MEDIA_SUBDIR+'/ に置き、入稿時にアップロードします')
6335
+ +'。<b>複数選べます</b>(選択中の行に上から順に入ります)</div>'
6336
+ +'<button class="act primary" data-nm="file"'+(ok?'':' disabled')+'>'+kind+'を選ぶ</button>'
6337
+ +(ok?'':'<span class="sm" style="color:var(--faint)"> 先に保存フォルダを選んでください</span>')
6338
+ +'</div></div>';
6339
+ root.innerHTML='<div class="overlay"><div class="modal" id="newMediaWizard"><h3>'+kind+'を新規登録</h3>'
6340
+ +'<div class="msub">保存フォルダを決めてから、'+kind+'を選びます</div>'
6341
+ +'<div class="mbody">'+(error?'<div class="callout danger" data-nm-error>'+esc(error)+'</div>':'')+step1+step2+'</div>'
6342
+ +'<div class="mfoot"><button class="act" data-nm="cancel">キャンセル</button></div></div></div>';
6343
+ root.querySelector('[data-nm="cancel"]').addEventListener('click', close);
6344
+ const copyBtn=root.querySelector('[data-nm="copypath"]');
6345
+ if(copyBtn) copyBtn.addEventListener('click', async ()=>{
6346
+ try{ await navigator.clipboard.writeText(plannerEnv.plansDir); copyBtn.textContent='コピーしました'; }
6347
+ catch(e){ toast('コピーできませんでした。パスを選択してコピーしてください'); }
6348
+ });
6349
+ root.querySelectorAll('[data-nm="dir"],[data-nm="dirfresh"]').forEach(b=>b.addEventListener('click', async ()=>{
6350
+ if(busy) return; busy=true;
6351
+ let r;
6352
+ try{ r=await pickPlansFolder(b.dataset.nm==='dirfresh'); }
6353
+ catch(e){ r={ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)}; }
6354
+ finally{ busy=false; }
6355
+ console.info('[planner] 保存フォルダの選択:', r);
6356
+ /* 黙って元の画面に戻すと「選んだのに進まない」ように見える。キャンセルも理由を出す
6357
+ (フォルダ選択のあとに出る「編集を許可」を閉じてもキャンセル扱いになる) */
6358
+ if(r.ok) error='';
6359
+ else error = r.aborted
6360
+ ? 'フォルダの選択、または続けて出る「ファイルの編集を許可」がキャンセルされました。もう一度「保存フォルダを選ぶ」を押し、最後に「編集を許可」を押してください'
6361
+ : r.reason;
6362
+ render();
6363
+ if(r.ok){ try{ renderDrawer(); }catch(e){ recordError('drawer', e, 'サイドバーの再描画'); } }
6364
+ }));
6365
+ const fileBtn=root.querySelector('[data-nm="file"]');
6366
+ if(fileBtn) fileBtn.addEventListener('click', ()=>{
6367
+ if(!dirHandle || busy) return;
6368
+ /* ここで await を挟まずにピッカーを開く (ユーザー操作の有効期限内に呼ぶ) */
6369
+ pickNewMediaFiles(isVideo).then(handles=>{
6370
+ if(!handles.length) return; // ピッカーをキャンセル → モーダルに戻る
6371
+ close();
6372
+ continueNewMedia({handles, isVideo, fmt, target});
6373
+ });
6374
+ });
6375
+ };
6376
+ render();
6377
+ }
6378
+ /** 素材ファイルを選ばせる (複数可)。キャンセルなら [] */
6379
+ async function pickNewMediaFiles(isVideo){
6380
+ try{
6381
+ const handles = isVideo
6382
+ ? await window.showOpenFilePicker({ id:'ad-planner-video', multiple:true,
6383
+ types:[{description:'動画', accept:{'video/mp4':['.mp4'],'video/quicktime':['.mov']}}] })
6384
+ : await window.showOpenFilePicker({ id:'ad-planner-image', multiple:true,
6385
+ types:[{description:'画像', accept:{'image/jpeg':['.jpg','.jpeg'],'image/png':['.png']}}] });
6386
+ return handles || [];
6387
+ }catch(e){ return []; } // ユーザーがキャンセル
6388
+ }
6389
+
6390
+ /* ---- この計画で新規登録した素材 (管理番号 → ローカルパス) ----
6391
+ パスは「登録した行」ではなく**番号**に持つ。こうしないと、同じ番号を別の行で選んだときに
6392
+ パスが付かず、一覧にもサムネを出せない。mediaID がまだ無いので refs.media には入れない
6393
+ (空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
6394
+ st.project に置くので保存 JSON と往復する (入稿計画JSONには出ない)。 */
6395
+ function localMedia(){
6396
+ if(!st.project.localMedia || typeof st.project.localMedia!=='object') st.project.localMedia={};
6397
+ return st.project.localMedia;
6398
+ }
6399
+ /** 番号に登録したローカル素材のパスを、その行のパス列へ入れる (行の形式と素材の種類が合うときだけ) */
6400
+ function applyLocalMediaPaths(item, no){
6401
+ if(!item || item.kind!=='a') return;
6402
+ const e=localMedia()[String(no)]; if(!e) return;
6403
+ const F=newMediaFields(pf().fmtOf(item.a)); if(!F) return;
6404
+ if(F.video && e.video){ setVarByField(item,'ads',F.video,e.video); setVarByField(item,'ads',F.thumb,e.thumb||''); }
6405
+ else if(F.image && e.image){ setVarByField(item,'ads',F.image,e.image); }
6406
+ }
6407
+ /* ローカル素材のサムネ。接続フォルダから読んで ObjectURL にする (登録直後は手元の Blob をそのまま使う) */
6408
+ const localThumbUrls=new Map();
6409
+ async function localFileUrl(rel){
6410
+ if(localThumbUrls.has(rel)) return localThumbUrls.get(rel);
6411
+ if(!dirHandle || !rel) return '';
6412
+ try{
6413
+ const parts=String(rel).split('/'); let d=dirHandle;
6414
+ for(let i=0;i<parts.length-1;i++) d=await d.getDirectoryHandle(parts[i]);
6415
+ const f=await (await d.getFileHandle(parts[parts.length-1])).getFile();
6416
+ const u=URL.createObjectURL(f); localThumbUrls.set(rel,u); return u;
6417
+ }catch(e){ return ''; }
6418
+ }
6419
+ function hydrateLocalThumbs(root){
6420
+ root.querySelectorAll('img[data-localthumb]').forEach(async img=>{
6421
+ const u=await localFileUrl(img.dataset.localthumb);
6422
+ if(u) img.src=u; else img.remove();
6423
+ });
6424
+ }
6425
+
6426
+ /** 新規登録の対象行。範囲選択中なら「選択範囲の広告行のうち、同じ種類 (動画 / 画像) の行」を上から順に。
6427
+ 選択が無ければ ▾ を押した行だけ。 */
6428
+ function newMediaTargets(target, isVideo){
6429
+ const rg=selRange(), vis=flatVisible(), out=[];
6430
+ if(rg && rg.r1>rg.r0){
6431
+ for(let r=rg.r0;r<=rg.r1;r++){
6432
+ const it=vis[r]; if(!it || it.kind!=='a') continue;
6433
+ const f=pf().fmtOf(it.a); if(!f || (f.apiMediaKind==='VIDEO')!==isVideo) continue;
6434
+ out.push({u:it.a.uid, row:r+1});
6435
+ }
6436
+ }
6437
+ if(!out.length){ const r=vis.findIndex(it=>it && it.kind==='a' && it.a.uid===target.u); out.push({u:target.u, row:r+1}); }
6438
+ return out;
6439
+ }
6440
+
6441
+ /** 選んだ素材から (動画なら 0 秒サムネを抜いて) 番号と行を確認するモーダルへ進む */
6442
+ async function continueNewMedia({handles, isVideo, fmt, target}){
6443
+ const items=[];
6444
+ for(const handle of handles){
6445
+ const file=await handle.getFile();
6446
+ let frame=null;
6447
+ if(isVideo){
6448
+ try{ frame=await grabVideoFrame(file, 0); }
6449
+ catch(e){ recordError('videoFrame', e, '動画サムネの抽出'); toast('動画サムネを作れませんでした('+file.name+'): '+e.message); return; }
6450
+ }
6451
+ items.push({handle, file, frame, sec:0});
6452
+ }
6453
+ if(!items.length) return;
6454
+ openNewMediaModal({items, isVideo, fmt, target, targets:newMediaTargets(target, isVideo)});
6455
+ }
6456
+
6457
+ /** 管理番号と入れる行を決めるモーダル。動画はサムネのプレビューと抽出位置もここで出す。
6458
+ 番号は空き番号からの連番を初期値に出し、書き換えられる。行より素材が多い分は一覧にだけ登録する。 */
6459
+ function openNewMediaModal(ctx){
6460
+ const n0=Number(nextMediaNo());
6461
+ ctx.items.forEach((it,i)=>{ it.no=String(n0+i); it.row=ctx.targets[i]||null; });
6462
+ const urls=ctx.items.map(it=>URL.createObjectURL(ctx.isVideo?it.frame.blob:it.file));
6463
+ const revoke=()=>urls.forEach(u=>{ try{ URL.revokeObjectURL(u); }catch(e){} });
6464
+ const kind=ctx.isVideo?'動画':'画像';
6465
+ const root=document.getElementById('modalRoot');
6466
+ const close=()=>{ root.innerHTML=''; };
6467
+ const rowsHtml=()=>ctx.items.map((it,i)=>'<div class="nmitem" data-i="'+i+'">'
6468
+ +'<img src="'+esc(urls[i])+'" alt="">'
6469
+ +'<div class="nmmeta"><div class="mono nmfile">'+esc(it.file.name)+'</div>'
6470
+ +'<div class="nmfields"><label>管理番号 <input type="text" class="nm-no" value="'+esc(it.no)+'"></label>'
6471
+ +(ctx.isVideo?'<label>サムネ位置(秒) <input type="number" min="0" class="nm-sec" value="'+it.sec+'"></label>':'')
6472
+ +'</div><div class="hint">'+(it.row?('広告 '+it.row.row+' 行目に入れます'):'行が足りないため、一覧にだけ登録します(あとで他の行から選べます)')+'</div></div></div>').join('');
6473
+ root.innerHTML='<div class="overlay"><div class="modal" id="newMediaModal"><h3>'+kind+'を新規登録('+ctx.items.length+'件)</h3>'
6474
+ +'<div class="msub">番号は空き番号からの連番です。広告名に出る番号なので、運用の番号に変えられます</div>'
6475
+ +'<div class="mbody">'+rowsHtml()+'</div>'
6476
+ +'<div class="mfoot"><button class="act" data-mo="cancel">キャンセル</button><button class="act primary" data-mo="ok">登録する</button></div></div></div>';
6477
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',()=>{ close(); revoke(); });
6478
+ root.querySelectorAll('.nm-sec').forEach(el=>el.addEventListener('change', async ()=>{
6479
+ const i=Number(el.closest('.nmitem').dataset.i), it=ctx.items[i];
6480
+ const sec=Math.max(0, Number(el.value)||0);
6481
+ try{
6482
+ it.frame=await grabVideoFrame(it.file, sec); it.sec=sec;
6483
+ try{ URL.revokeObjectURL(urls[i]); }catch(e){}
6484
+ urls[i]=URL.createObjectURL(it.frame.blob);
6485
+ el.closest('.nmitem').querySelector('img').src=urls[i];
6486
+ }catch(e){ recordError('videoFrame', e, '動画サムネの再抽出'); toast('サムネを取り直せませんでした: '+e.message); }
6487
+ }));
6488
+ root.querySelector('[data-mo="ok"]').addEventListener('click', async ()=>{
6489
+ const nos=[...root.querySelectorAll('.nm-no')].map(el=>String(el.value||'').trim());
6490
+ /* 既存番号への上書きは許さない。参照表の mediaID を持つ番号にパスが被さると、
6491
+ どちらが入稿に使われるか画面から分からなくなる。 */
6492
+ for(const no of nos){
6493
+ if(no==='') return toast('管理番号を入れてください');
6494
+ if((st.refs.media||{})[no]) return toast('番号 '+no+' は参照データに登録済みです。別の番号にしてください');
6495
+ if(localMedia()[no]) return toast('番号 '+no+' はこの計画で登録済みです。別の番号にしてください');
6496
+ }
6497
+ if(new Set(nos).size!==nos.length) return toast('同じ管理番号が重複しています');
6498
+ ctx.items.forEach((it,i)=>{ it.no=nos[i]; });
6499
+ close(); revoke();
6500
+ for(const it of ctx.items){
6501
+ const ok=await applyNewMedia({isVideo:ctx.isVideo, fmt:ctx.fmt, file:it.file, handle:it.handle, frame:it.frame, sec:it.sec,
6502
+ target:it.row?{u:it.row.u, f:ctx.target.f}:null}, it.no, {quiet:true});
6503
+ if(!ok) return;
6504
+ }
6505
+ const placed=ctx.items.filter(it=>it.row).length;
6506
+ toast(ctx.items.length+'件を登録しました(行に入れたもの '+placed+'件・'+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
6507
+ });
6508
+ }
6509
+
6510
+ /** 素材を 素材/ へ書き出して番号に登録し、target があればその行に番号とパスを入れる。
6511
+ @returns {Promise<boolean>} 書き出せたか */
6512
+ async function applyNewMedia(ctx, no, opts){
6513
+ const F=newMediaFields(ctx.fmt); if(!F) return false;
6514
+ const entry={file:ctx.file.name};
6515
+ try{
6516
+ if(ctx.isVideo){
6517
+ const base=safeFileName(ctx.file.name).replace(/\.[^.]+$/,'');
6518
+ entry.video=await videoRelPath(ctx.handle, ctx.file);
6519
+ entry.thumb=await writeMediaFile(base+'_'+ctx.sec+'s.jpg', ctx.frame.blob);
6520
+ localThumbUrls.set(entry.thumb, URL.createObjectURL(ctx.frame.blob));
6521
+ }else{
6522
+ entry.image=await videoRelPath(ctx.handle, ctx.file);
6523
+ localThumbUrls.set(entry.image, URL.createObjectURL(ctx.file));
6524
+ }
6525
+ }catch(e){ recordError('mediaWrite', e, '素材の書き出し'); toast('素材を書き出せませんでした: '+e.message); return false; }
6526
+
6527
+ mutate(()=>{ localMedia()[String(no)]=entry; });
6528
+ if(ctx.target){
6529
+ const fa=findAd(ctx.target.u);
6530
+ if(!fa){ toast('対象の広告行が見つかりません'); return false; }
6531
+ /* ⚠ setVar の第1引数は **flatVisible() の行オブジェクト**(`rowOf` が kind で実体を選ぶ)。
6532
+ 広告オブジェクトを直接渡すと `rowOf` が行を取れず、**黙って何も書かずに返る**。 */
6533
+ const item={kind:'a', a:fa.a, g:fa.g, c:fa.c};
6534
+ const num=/^\d+$/.test(String(no))?Number(no):no;
6535
+ mutate(()=>{ setVarByField(item,'ads',ctx.target.f||'imageNo',num); applyLocalMediaPaths(item, no); });
6536
+ }
6537
+ if(!(opts&&opts.quiet)) toast('番号 '+no+' で登録しました('+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
6538
+ if(typeof renderDrawer==='function') renderDrawer();
6539
+ return true;
6540
+ }
6541
+
5937
6542
  /* ============================================================
5938
6543
  B-1: アカウント別参照データ (アカウント/<媒体>/ フォルダ + <名前>.json)
5939
6544
  File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
@@ -6092,6 +6697,7 @@ window.applyAccountJson=function(obj){
6092
6697
  st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
6093
6698
  migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
6094
6699
  migratePlatformValues(st.campaigns); // 媒体側の旧値を現行の語彙へ
6700
+ st.project.localMedia=samplePlanLocalMedia();
6095
6701
  planMismatchMsg='';
6096
6702
  if(typeof renderPlanWarn==='function') renderPlanWarn();
6097
6703
  }
@@ -6447,14 +7053,27 @@ function openHelpModal(){
6447
7053
 
6448
7054
  /* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
6449
7055
  初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
7056
+ /** サンプルプラン.json に書き戻す中身。
7057
+ localMedia (この計画で新規登録した素材: 番号 → ローカルパス) も持つ。無いと保存して開き直したときに、
7058
+ 一覧にだけ登録した素材と、番号に紐づくパス・サムネが消える (行に入ったパスは campaigns 側に残る)。 */
7059
+ function samplePlanData(){
7060
+ const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
7061
+ const lm=st.project.localMedia;
7062
+ if(lm && typeof lm==='object' && Object.keys(lm).length) data.localMedia=lm;
7063
+ return data;
7064
+ }
7065
+ /** サンプルプランの localMedia を読む (無い・壊れているときは空) */
7066
+ function samplePlanLocalMedia(){
7067
+ const lm=samplePlan && samplePlan.localMedia;
7068
+ return (lm && typeof lm==='object' && !Array.isArray(lm)) ? JSON.parse(JSON.stringify(lm)) : {};
7069
+ }
6450
7070
  async function updateSamplePlan(){
6451
7071
  /* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
6452
7072
  ・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
6453
7073
  無警告で読み込まれる(誤入稿に直結)ので必ず出す
6454
7074
  ・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
6455
7075
  書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
6456
- const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
6457
- const json=JSON.stringify(data,null,1);
7076
+ const json=JSON.stringify(samplePlanData(),null,1);
6458
7077
  if(!samplePlanFileHandle){ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
6459
7078
  if(samplePlanFileHandle){ try{ let perm=await samplePlanFileHandle.queryPermission({mode:'readwrite'}); if(perm!=='granted') perm=await samplePlanFileHandle.requestPermission({mode:'readwrite'}); if(perm!=='granted') samplePlanFileHandle=null; }catch(e){ samplePlanFileHandle=null; } }
6460
7079
  if(!samplePlanFileHandle){
@@ -6562,7 +7181,7 @@ document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(
6562
7181
  document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-m]'); if(!b) return; clearSel(); closeDrawer(); planMode=b.dataset.m; document.querySelectorAll('#modeSeg button').forEach(x=>x.classList.toggle('active',x===b)); render(); });
6563
7182
  /* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
6564
7183
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
6565
- document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
7184
+ document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','video','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
6566
7185
  /* 案件テンプレ: チップ直接編集 */
6567
7186
  document.getElementById('tplChips').addEventListener('change',e=>{
6568
7187
  /* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
@@ -6674,7 +7293,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
6674
7293
  pendingDrawer=null; dragMoved=false;
6675
7294
  // S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
6676
7295
  const col=colDefs()[c];
6677
- if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
7296
+ if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); /* Shift+クリックの範囲選択でもドロワーを開く (開いていれば選択範囲に合わせて描き直す) */ if(col && col.kind==='refcombo' && selA.c===c) pendingDrawer={kind:'adRange', u:cell.dataset.u, f:cell.dataset.f}; /* 同じ列を縦に選んだときだけ (矩形選択は従来どおり開かない) */ } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
6678
7297
  applySelection();
6679
7298
  });
6680
7299
  sheetHostEl.addEventListener('mouseover',e=>{
@@ -6698,7 +7317,7 @@ document.getElementById('drawerBackdrop').addEventListener('click',closeDrawer);
6698
7317
  document.getElementById('drawerSearch').addEventListener('input',renderDrawer);
6699
7318
  document.getElementById('drawerList').addEventListener('click',e=>{
6700
7319
  /* サムネのクリックは拡大。行の選択には落とさない (見たいだけで選ぶとは限らない) */
6701
- const th=e.target.closest('.ddthumb');
7320
+ const th=e.target.closest('.ddthumb[data-zoom]'); // ローカル素材のサムネ (data-localthumb) は拡大しない
6702
7321
  if(th){ e.stopPropagation(); openThumbZoom(th.dataset.zoom); return; }
6703
7322
  const row=e.target.closest('.ddrow'); if(!row) return; if(!drawerTarget) return;
6704
7323
  if(drawerTarget.type==='tg'){ drawerTgToggle(row.dataset.tgval); }
@@ -6707,21 +7326,37 @@ document.getElementById('drawerList').addEventListener('click',e=>{
6707
7326
  else if(drawerTarget.type==='agref'){ if(row.dataset.agid) addExistingAdGroup(row.dataset.agid); }
6708
7327
  else if(drawerTarget.type==='cppick'){ if(row.dataset.cppick){ if(drawerTarget.mode==='addNew') addNewAdGroupToCampaign(row.dataset.cppick); else moveGroupToCampaign(drawerTarget.gUid, row.dataset.cppick); } }
6709
7328
  else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
7329
+ /* 「+ 新規登録」。保存フォルダ → 素材 の2ステップをモーダルで案内する */
7330
+ else if(row.dataset.newmedia){ openNewMediaWizard(); }
7331
+ else if(row.dataset.seq){ applySequentialParamCodes(); renderDrawer(); }
6710
7332
  else { drawerPick(row.dataset.no); }
6711
7333
  });
6712
7334
  /* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
6713
- document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
7335
+ document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='adRange'){ if(document.getElementById('refDrawer').classList.contains('open') && drawerTarget && drawerTarget.f===pendingDrawer.f) renderDrawer(); else openDrawerFor(pendingDrawer.u,pendingDrawer.f); } else if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
6714
7336
 
6715
7337
  /* ---- コピー / 貼付 ---- */
7338
+ /* シートの自前コピペ (セル範囲 → TSV) を使う場面か。
7339
+ モーダル・サイドバー・チップの入力欄や、シートの外の文字を選んでいるときはブラウザ標準に任せる。
7340
+ 以前はシート表示中なら常に横取りしていたため、モーダルのパスやエラー文をコピーできず、
7341
+ モーダルの入力欄にも貼れなかった (セル範囲が選択されたままだと貼付がシートへ流れた)。 */
7342
+ function gridClipboardActive(){
7343
+ if(activeView!=='sheet'||planMode!=='sheet') return false;
7344
+ const mr=document.getElementById('modalRoot'); if(mr && mr.children.length) return false;
7345
+ const ae=document.activeElement;
7346
+ if(ae && ae.matches && ae.matches('input,textarea,select,[contenteditable="true"]') && !ae.classList.contains('cell-in')) return false;
7347
+ const sel=window.getSelection && window.getSelection();
7348
+ if(sel && !sel.isCollapsed && sel.anchorNode){ const host=document.getElementById('sheetHost'); if(!host || !host.contains(sel.anchorNode)) return false; }
7349
+ return true;
7350
+ }
6716
7351
  document.addEventListener('copy',e=>{
6717
- if(activeView!=='sheet'||planMode!=='sheet') return;
7352
+ if(!gridClipboardActive()) return;
6718
7353
  const rg=selRange();
6719
7354
  if(rg){ const t=copySelText(); if(t!=null){ e.clipboardData.setData('text/plain',t); e.preventDefault(); } return; } // セル選択→常に自前TSV(ラベル/ボタンのDOMテキスト混入を防ぐ)
6720
7355
  const ae=document.activeElement; // セル選択なし: 単一inputの部分テキスト選択のみnative許可
6721
7356
  if(ae&&ae.classList&&ae.classList.contains('cell-in')&&ae.selectionStart!=null&&ae.selectionStart!==ae.selectionEnd) return;
6722
7357
  e.preventDefault();
6723
7358
  });
6724
- document.addEventListener('paste',e=>{ if(activeView!=='sheet'||planMode!=='sheet') return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
7359
+ document.addEventListener('paste',e=>{ if(!gridClipboardActive()) return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
6725
7360
  document.addEventListener('keydown',e=>{
6726
7361
  /* 自動で消えないトーストはどのビューからでも出る(書き出しボタンは常設ヘッダー)。
6727
7362
  ビューのガードより手前で閉じないと、title の「閉じる (Esc)」と食い違う */