SeaWolf-AI commited on
Commit
5808c79
ยท
verified ยท
1 Parent(s): 77a5a1b

fix: Seedance content-policy survival \u2014 anonymized image prompts + auto-fallback chain + per-anchor retry UI

Browse files
Files changed (2) hide show
  1. aether_ad/media/fal_client.py +10 -3
  2. static/app.js +105 -40
aether_ad/media/fal_client.py CHANGED
@@ -30,10 +30,17 @@ _IMAGE_QUALITY_SUFFIX = (
30
  "Style: hyperrealistic cinematography, IMAX-grade composition, ARRI Alexa "
31
  "cinema look, anamorphic depth of field, professional color grading, 8K "
32
  "detail, editorial magazine framing, golden-hour or rim lighting, motivated "
33
- "natural lighting only. Skin and surfaces have authentic texture (no plastic "
34
- "or waxy look). Subjects are sharply in focus, background has cinematic bokeh. "
 
 
 
 
 
 
35
  "Avoid: blurry, low-resolution, amateur snapshot, watermark, plastic skin, "
36
- "morphing artifacts, deformed hands, extra fingers, wrong anatomy. "
 
37
  "Strictly NO text, NO letters, NO Korean characters, NO captions, NO subtitles, "
38
  "NO writing, NO typography, NO logos anywhere in the image. Pure visual scene only."
39
  )
 
30
  "Style: hyperrealistic cinematography, IMAX-grade composition, ARRI Alexa "
31
  "cinema look, anamorphic depth of field, professional color grading, 8K "
32
  "detail, editorial magazine framing, golden-hour or rim lighting, motivated "
33
+ "natural lighting only. Surfaces have authentic texture (no plastic or waxy "
34
+ "look). Subjects are sharply in focus, background has cinematic bokeh. "
35
+ # โ”€โ”€ Anonymization (Seedance 2.0 i2v rejects recognizable real-person likenesses)
36
+ "If human subjects appear, render them anonymized: prefer back-view, "
37
+ "side-profile, silhouette, half-shadow, hair-obscured, hands-only, or "
38
+ "subject framed from behind. Avoid clear frontal close-up portraits that "
39
+ "could resemble identifiable real people. Stylized, anonymous, atmospheric. "
40
+ # โ”€โ”€ General negatives
41
  "Avoid: blurry, low-resolution, amateur snapshot, watermark, plastic skin, "
42
+ "morphing artifacts, deformed hands, extra fingers, wrong anatomy, frontal "
43
+ "celebrity-like portraits. "
44
  "Strictly NO text, NO letters, NO Korean characters, NO captions, NO subtitles, "
45
  "NO writing, NO typography, NO logos anywhere in the image. Pure visual scene only."
46
  )
static/app.js CHANGED
@@ -662,58 +662,105 @@ function buildCombinedScenePrompt(beats) {
662
  return lines.join("\n");
663
  }
664
 
665
- /** Generate a single full-spot video (Seedance 2.0). */
666
- async function generateFinalVideo(sIdx) {
 
 
 
 
 
 
 
 
 
 
667
  const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed;
668
  if (!seed) return;
669
  const beats = seed.beats || [];
670
  if (!beats.length) return;
671
 
672
- // Use the FIRST chained image as the visual anchor โ€” Seedance animates from there.
673
- const anchorImg = MEDIA_STATE[sIdx]?.[0]?.image_url;
674
- if (!anchorImg) {
 
 
 
 
675
  FINAL_VIDEO_STATE[sIdx] = {
676
  status: 'error',
677
- message: '์ด๋ฏธ์ง€๊ฐ€ ์•„์ง ์ƒ์„ฑ๋˜์ง€ ์•Š์•„ ๋น„๋””์˜ค ์ƒ์„ฑ์„ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.',
678
  };
679
  renderFinalVideoSection(sIdx);
680
  return;
681
  }
 
 
 
 
 
682
 
683
  const totalSec = beats[beats.length - 1].time_range[1];
684
  const combinedScene = buildCombinedScenePrompt(beats);
685
 
686
- FINAL_VIDEO_STATE[sIdx] = {
687
- status: 'loading',
688
- target_seconds: totalSec,
689
- message: `๐ŸŽฌ Seedance 2.0์ด ${totalSec}์ดˆ ๋‹จ์ผ ์˜์ƒ ์ƒ์„ฑ ์ค‘โ€ฆ (3~6๋ถ„ ์†Œ์š”)`,
690
- };
691
- renderFinalVideoSection(sIdx);
692
-
693
- try {
694
- const r = await fetch('/api/media/video', {
695
- method: 'POST',
696
- headers: {'Content-Type': 'application/json'},
697
- body: JSON.stringify({
698
- scene: combinedScene,
699
- image_url: anchorImg,
700
- duration_seconds: totalSec,
701
- }),
702
- });
703
- const data = await r.json();
704
- if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
705
- FINAL_VIDEO_STATE[sIdx] = {
706
- status: 'ready',
707
- video_url: data.video_url,
708
- target_seconds: totalSec,
709
- };
710
- } catch (e) {
711
  FINAL_VIDEO_STATE[sIdx] = {
712
- status: 'error',
713
  target_seconds: totalSec,
714
- message: `๋น„๋””์˜ค ์ƒ์„ฑ ์‹คํŒจ: ${e.message || e}`,
 
 
 
715
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  }
 
 
 
 
 
 
 
717
  renderFinalVideoSection(sIdx);
718
  }
719
 
@@ -739,12 +786,16 @@ function renderFinalVideoSection(sIdx) {
739
  }
740
 
741
  if (st.video_url) {
 
 
 
 
742
  html.push(`
743
  <div class="media-thumb stitch-thumb">
744
  <video src="${escapeAttr(st.video_url)}" controls playsinline preload="metadata"></video>
745
  </div>
746
  <div class="stitch-progress">
747
- ${totalSec}์ดˆ ์˜์ƒ ยท 5๊ฐœ ์”ฌ์„ ๋‹จ์ผ i2v ์ฝœ๋กœ ํ†ตํ•ฉ ์ƒ์„ฑ ยท ๊ธฐ์ค€ ์ด๋ฏธ์ง€: ๋น„ํŠธ 1
748
  </div>
749
  <div class="stitch-actions">
750
  <a class="btn-stitch-download"
@@ -756,6 +807,13 @@ function renderFinalVideoSection(sIdx) {
756
  } else if (st.status !== 'loading') {
757
  const enough = imagesReady >= 1;
758
  const disabled = !enough ? 'disabled' : '';
 
 
 
 
 
 
 
759
  html.push(`
760
  <div class="stitch-progress">
761
  ์ด๋ฏธ์ง€ ${imagesReady}/${beats.length} ์ค€๋น„
@@ -764,6 +822,7 @@ function renderFinalVideoSection(sIdx) {
764
  <button type="button" class="btn-stitch" data-final-seed="${sIdx}" ${disabled}>
765
  ๐ŸŽฌ ${totalSec}์ดˆ ๊ด‘๊ณ  ์˜์ƒ ์ƒ์„ฑ (Seedance 2.0, ํ•œ ๋ฒˆ ํ˜ธ์ถœ)
766
  </button>
 
767
  `);
768
  }
769
 
@@ -952,14 +1011,21 @@ function escapeAttr(s) {
952
  return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
953
  }
954
 
955
- /* Delegate clicks on per-beat image buttons + final-video regen button. */
956
  document.addEventListener('click', (e) => {
 
 
 
 
 
 
 
 
 
 
957
  const finalBtn = e.target.closest('.btn-stitch');
958
  if (finalBtn && !finalBtn.disabled) {
959
- const sIdx = parseInt(
960
- finalBtn.dataset.finalSeed ?? finalBtn.dataset.stitchSeed,
961
- 10,
962
- );
963
  if (!Number.isNaN(sIdx)) generateFinalVideo(sIdx);
964
  return;
965
  }
@@ -970,7 +1036,6 @@ document.addEventListener('click', (e) => {
970
  if (btn.dataset.mediaBtn === 'image') {
971
  manualGenerateImage(sIdx, bIdx);
972
  }
973
- // Per-beat video buttons removed โ€” single Seedance video at the bottom only.
974
  });
975
 
976
 
 
662
  return lines.join("\n");
663
  }
664
 
665
+ function _isContentPolicyError(msg) {
666
+ const m = String(msg || '').toLowerCase();
667
+ return m.includes('content_policy_violation')
668
+ || m.includes('content policy')
669
+ || m.includes('likenesses of real people')
670
+ || m.includes('private information');
671
+ }
672
+
673
+ /** Generate a single full-spot video (Seedance 2.0).
674
+ * Anchor = first chained image. If Seedance rejects on content policy,
675
+ * automatically retry with the next image in the chain. */
676
+ async function generateFinalVideo(sIdx, anchorBeatIdx = 0) {
677
  const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed;
678
  if (!seed) return;
679
  const beats = seed.beats || [];
680
  if (!beats.length) return;
681
 
682
+ // Build candidate list (any beat with an image) starting from anchorBeatIdx
683
+ const allCandidates = [];
684
+ for (let i = 0; i < beats.length; i++) {
685
+ const u = MEDIA_STATE[sIdx]?.[i]?.image_url;
686
+ if (u) allCandidates.push({ beatIdx: i, url: u });
687
+ }
688
+ if (!allCandidates.length) {
689
  FINAL_VIDEO_STATE[sIdx] = {
690
  status: 'error',
691
+ message: '์ด๋ฏธ์ง€๊ฐ€ ์•„์ง ์ƒ์„ฑ๋˜์ง€ ์•Š์•„ ๋น„๋””์˜ค๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.',
692
  };
693
  renderFinalVideoSection(sIdx);
694
  return;
695
  }
696
+ // Reorder so anchorBeatIdx comes first, then the rest in beat order
697
+ const candidates = [
698
+ ...allCandidates.filter(c => c.beatIdx === anchorBeatIdx),
699
+ ...allCandidates.filter(c => c.beatIdx !== anchorBeatIdx),
700
+ ];
701
 
702
  const totalSec = beats[beats.length - 1].time_range[1];
703
  const combinedScene = buildCombinedScenePrompt(beats);
704
 
705
+ let lastErr = '';
706
+ let policyRejections = 0;
707
+ for (let attempt = 0; attempt < candidates.length; attempt++) {
708
+ const cand = candidates[attempt];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  FINAL_VIDEO_STATE[sIdx] = {
710
+ status: 'loading',
711
  target_seconds: totalSec,
712
+ anchor_beat: cand.beatIdx,
713
+ message: attempt === 0
714
+ ? `๐ŸŽฌ Seedance 2.0์ด ${totalSec}์ดˆ ์˜์ƒ ์ƒ์„ฑ ์ค‘โ€ฆ (์•ต์ปค: ๋น„ํŠธ ${cand.beatIdx + 1}, 3~6๋ถ„)`
715
+ : `โš ๏ธ ์ •์ฑ… ๊ฑฐ๋ถ€ ${policyRejections}ํšŒ โ€” ๋น„ํŠธ ${cand.beatIdx + 1} ์ด๋ฏธ์ง€๋กœ ์žฌ์‹œ๋„ ์ค‘โ€ฆ`,
716
  };
717
+ renderFinalVideoSection(sIdx);
718
+
719
+ try {
720
+ const r = await fetch('/api/media/video', {
721
+ method: 'POST',
722
+ headers: {'Content-Type': 'application/json'},
723
+ body: JSON.stringify({
724
+ scene: combinedScene,
725
+ image_url: cand.url,
726
+ duration_seconds: totalSec,
727
+ }),
728
+ });
729
+ const data = await r.json();
730
+ if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
731
+ FINAL_VIDEO_STATE[sIdx] = {
732
+ status: 'ready',
733
+ video_url: data.video_url,
734
+ target_seconds: totalSec,
735
+ anchor_beat: cand.beatIdx,
736
+ policy_retries: policyRejections,
737
+ };
738
+ renderFinalVideoSection(sIdx);
739
+ return;
740
+ } catch (e) {
741
+ const msg = e.message || String(e);
742
+ lastErr = msg;
743
+ if (_isContentPolicyError(msg)) {
744
+ policyRejections += 1;
745
+ continue; // try next candidate
746
+ }
747
+ // non-policy error: stop, show
748
+ FINAL_VIDEO_STATE[sIdx] = {
749
+ status: 'error',
750
+ target_seconds: totalSec,
751
+ message: `๋น„๋””์˜ค ์ƒ์„ฑ ์‹คํŒจ: ${msg}`,
752
+ };
753
+ renderFinalVideoSection(sIdx);
754
+ return;
755
+ }
756
  }
757
+ // All candidates rejected by policy
758
+ FINAL_VIDEO_STATE[sIdx] = {
759
+ status: 'error',
760
+ target_seconds: totalSec,
761
+ message: `Seedance๊ฐ€ ๋ชจ๋“  ์ด๋ฏธ์ง€๋ฅผ ์ •์ฑ…์œผ๋กœ ๊ฑฐ๋ถ€ (${policyRejections}ํšŒ). `
762
+ + `์ด๋ฏธ์ง€๋ฅผ ์žฌ์ƒ์„ฑํ•œ ๋’ค (์ธ๋ฌผ ์ •๋ฉด ํฌํŠธ๋ ˆ์ดํŠธ ํšŒํ”ผ) ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”. ${lastErr.slice(0, 200)}`,
763
+ };
764
  renderFinalVideoSection(sIdx);
765
  }
766
 
 
786
  }
787
 
788
  if (st.video_url) {
789
+ const anchorIdx = (typeof st.anchor_beat === 'number') ? st.anchor_beat + 1 : 1;
790
+ const retryNote = st.policy_retries
791
+ ? ` ยท ์ •์ฑ… ๊ฑฐ๋ถ€ ${st.policy_retries}ํšŒ ํ›„ ์žฌ์‹œ๋„ ์„ฑ๊ณต`
792
+ : '';
793
  html.push(`
794
  <div class="media-thumb stitch-thumb">
795
  <video src="${escapeAttr(st.video_url)}" controls playsinline preload="metadata"></video>
796
  </div>
797
  <div class="stitch-progress">
798
+ ${totalSec}์ดˆ ์˜์ƒ ยท ${beats.length}๊ฐœ ์”ฌ ํ†ตํ•ฉ i2v ยท ์•ต์ปค ์ด๋ฏธ์ง€: ๋น„ํŠธ ${anchorIdx}${retryNote}
799
  </div>
800
  <div class="stitch-actions">
801
  <a class="btn-stitch-download"
 
807
  } else if (st.status !== 'loading') {
808
  const enough = imagesReady >= 1;
809
  const disabled = !enough ? 'disabled' : '';
810
+ // If a previous run errored on policy, surface a "Try with another anchor" hint
811
+ const anchorButtons = (st.status === 'error' && imagesReady > 1)
812
+ ? beats.map((_, i) => MEDIA_STATE[sIdx]?.[i]?.image_url
813
+ ? `<button type="button" class="btn-media" data-final-seed="${sIdx}" data-anchor-beat="${i}">๐ŸŽฏ ๋น„ํŠธ ${i + 1} ์ด๋ฏธ์ง€๋กœ ์žฌ์‹œ๋„</button>`
814
+ : ''
815
+ ).join('')
816
+ : '';
817
  html.push(`
818
  <div class="stitch-progress">
819
  ์ด๋ฏธ์ง€ ${imagesReady}/${beats.length} ์ค€๋น„
 
822
  <button type="button" class="btn-stitch" data-final-seed="${sIdx}" ${disabled}>
823
  ๐ŸŽฌ ${totalSec}์ดˆ ๊ด‘๊ณ  ์˜์ƒ ์ƒ์„ฑ (Seedance 2.0, ํ•œ ๋ฒˆ ํ˜ธ์ถœ)
824
  </button>
825
+ ${anchorButtons ? `<div class="media-controls" style="flex-direction:row;flex-wrap:wrap;">${anchorButtons}</div>` : ''}
826
  `);
827
  }
828
 
 
1011
  return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1012
  }
1013
 
1014
+ /* Delegate clicks on per-beat image buttons + final-video regen + anchor retry. */
1015
  document.addEventListener('click', (e) => {
1016
+ // Final-video regen / try-another-anchor (.btn-media or .btn-stitch with data-final-seed)
1017
+ const finalEl = e.target.closest('[data-final-seed]');
1018
+ if (finalEl && !finalEl.disabled) {
1019
+ const sIdx = parseInt(finalEl.dataset.finalSeed ?? finalEl.dataset.stitchSeed, 10);
1020
+ const anchor = finalEl.dataset.anchorBeat;
1021
+ if (!Number.isNaN(sIdx)) {
1022
+ generateFinalVideo(sIdx, anchor !== undefined ? parseInt(anchor, 10) : 0);
1023
+ }
1024
+ return;
1025
+ }
1026
  const finalBtn = e.target.closest('.btn-stitch');
1027
  if (finalBtn && !finalBtn.disabled) {
1028
+ const sIdx = parseInt(finalBtn.dataset.stitchSeed, 10);
 
 
 
1029
  if (!Number.isNaN(sIdx)) generateFinalVideo(sIdx);
1030
  return;
1031
  }
 
1036
  if (btn.dataset.mediaBtn === 'image') {
1037
  manualGenerateImage(sIdx, bIdx);
1038
  }
 
1039
  });
1040
 
1041