asset: 归档第一集已生成镜头与测试素材,排除可再生缓存
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Scene-level conform. Only accepted shots qualify for delivery unless --draft."""
|
||||
import argparse,json,subprocess,sys,shutil
|
||||
from pathlib import Path
|
||||
from production import ROOT,SAMPLE,read,write,cmd,probe,FFMPEG,FFPROBE,log
|
||||
MANIFEST=read(ROOT/'source_manifest.json')
|
||||
LINES={d['id']:d for d in MANIFEST['dialogue']}
|
||||
|
||||
def edit_spec(shot):
|
||||
f=ROOT/'shots'/shot['id']/'accepted.json'
|
||||
decision=read(f) if f.exists() else {}
|
||||
return float(decision.get('edit_in_seconds',0)),float(decision.get('edit_seconds',shot['seconds']))
|
||||
|
||||
def edit_duration(shot):return edit_spec(shot)[1]
|
||||
|
||||
def stamp(s):
|
||||
ms=round(s*1000);h,ms=divmod(ms,3600000);m,ms=divmod(ms,60000);sec,ms=divmod(ms,1000)
|
||||
return f'{h:02}:{m:02}:{sec:02},{ms:03}'
|
||||
def escaped_filter_path(p):return p.as_posix().replace(':','\\:').replace("'","\\'")
|
||||
def subtitles(shots):
|
||||
entries=[];offset=0
|
||||
for shot in shots:
|
||||
f=ROOT/'shots'/shot['id'];ids=shot['dialogue_ids'];start_in,seconds=edit_spec(shot)
|
||||
expected=[LINES[x]['text'] for x in ids]
|
||||
if expected:
|
||||
speech=f/'speech_check_small.json' if (f/'speech_check_small.json').exists() else f/'speech_check.json'
|
||||
segments=read(speech).get('segments',[]) if speech.exists() else []
|
||||
if shot['scene']==6:
|
||||
st,en=(.2,3.2) if shot['id'].endswith('C02') else (2.65,4.95)
|
||||
elif segments:
|
||||
st=max(0,segments[0]['start']-start_in-.08);en=min(seconds,segments[-1]['end']-start_in+.12)
|
||||
else:st,en=.25,max(.5,seconds-.25)
|
||||
if en-st<.4:st,en=.25,seconds-.2
|
||||
total=sum(max(1,len(t)) for t in expected);cursor=st
|
||||
for line in expected:
|
||||
duration=(en-st)*max(1,len(line))/total
|
||||
entries.append((offset+cursor,offset+cursor+duration,line));cursor+=duration
|
||||
offset+=seconds
|
||||
return '\n\n'.join(f'{i+1}\n{stamp(a)} --> {stamp(b)}\n{t}' for i,(a,b,t) in enumerate(entries))+'\n'
|
||||
def normalized(shot,draft=False):
|
||||
f=ROOT/'shots'/shot['id'];assert (f/'outputs.json').exists(),f'{shot["id"]}: still rendering'
|
||||
if not draft:assert (f/'accepted.json').exists(),f'{shot["id"]}: visual/dialogue review outstanding'
|
||||
if (f/'screen_overlay.json').exists() and not draft:
|
||||
assert (f/'post_outputs.json').exists(),f'{shot["id"]}: essential screen text still pending'
|
||||
output_file=f/'post_outputs.json' if (f/'post_outputs.json').exists() else f/'outputs.json'
|
||||
source=Path(read(output_file)[0]);out=f/'conform.mkv';start_in,seconds=edit_spec(shot)
|
||||
reframe=read(f/'reframe.json') if (f/'reframe.json').exists() else {}
|
||||
config={'source':str(source),'mtime':source.stat().st_mtime,'in':start_in,'seconds':seconds,'reframe':reframe,'version':3}
|
||||
if out.exists() and (f/'conform_source.json').exists() and read(f/'conform_source.json')==config:return out
|
||||
vf='scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=24,setpts=PTS-STARTPTS'
|
||||
if reframe.get('crop_top'):
|
||||
top=int(reframe['crop_top']);vf=f'crop=iw:ih-{top}:0:{top},'+vf
|
||||
af=f'aresample=48000:async=1:first_pts=0,apad,atrim=end_sample={round(seconds*48000)},asetpts=PTS-STARTPTS,afade=t=in:d=0.012,afade=t=out:st={max(0,seconds-.025)}:d=0.025'
|
||||
cmd([FFMPEG,'-hide_banner','-loglevel','error','-y','-ss',start_in,'-i',source,'-vf',vf,'-af',af,'-t',seconds,'-map','0:v:0','-map','0:a:0','-c:v','libx264','-crf','18','-preset','medium','-pix_fmt','yuv420p','-fps_mode','cfr','-c:a','pcm_s16le','-ar','48000','-ac','2',out])
|
||||
info=probe(out);assert abs(float(info['format']['duration'])-seconds)<.06
|
||||
write(f/'conform_source.json',config);return out
|
||||
def scene(number,draft=False):
|
||||
shots=[s for s in MANIFEST['shots'] if s['scene']==number];assert shots
|
||||
dest=ROOT/'scenes'/f'SC{number:02}';dest.mkdir(parents=True,exist_ok=True)
|
||||
if number==6:
|
||||
out=dest/'SC06_正片.mp4'
|
||||
shutil.copy2(SAMPLE/'EP01_泉堂祝福_40秒试看.mp4',out)
|
||||
write(dest/'probe.json',probe(out))
|
||||
write(dest/'assembly.json',{'output':str(out),'duration':40,'draft':False,'reused_final_sample':True,'shots':[s['id'] for s in shots]})
|
||||
return out
|
||||
sources=[normalized(s,draft) for s in shots]
|
||||
playlist=dest/'concat.txt';playlist.write_text(''.join("file '"+p.as_posix().replace("'","'\\''")+"'\n" for p in sources),encoding='utf-8')
|
||||
srt=dest/'台词.srt';srt.write_text(subtitles(shots),encoding='utf-8-sig')
|
||||
duration=sum(edit_duration(s) for s in shots)
|
||||
out=dest/(f'SC{number:02}_分场检查.mp4' if draft else f'SC{number:02}_正片.mp4')
|
||||
vf="subtitles='"+escaped_filter_path(srt)+"':force_style='FontName=Microsoft YaHei,FontSize=20,Outline=1,Shadow=0,MarginV=28'" if any(s['dialogue_ids'] for s in shots) else 'null'
|
||||
cmd([FFMPEG,'-hide_banner','-loglevel','error','-y','-f','concat','-safe','0','-i',playlist,'-vf',vf,'-t',duration,'-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p','-fps_mode','cfr','-r','24','-c:a','aac','-b:a','192k','-ar','48000','-movflags','+faststart',out])
|
||||
info=probe(out);video=next(x for x in info['streams'] if x['codec_type']=='video')
|
||||
assert video['avg_frame_rate']=='24/1'
|
||||
assert abs(float(info['format']['duration'])-duration)<.06
|
||||
write(dest/'probe.json',info)
|
||||
cmd([FFMPEG,'-hide_banner','-loglevel','error','-y','-i',out,'-vf',f'fps=1/{duration/12},scale=480:-1,tile=4x3','-frames:v',1,dest/'review.jpg'])
|
||||
write(dest/'assembly.json',{'output':str(out),'duration':duration,'draft':draft,'shots':[s['id'] for s in shots]})
|
||||
log('scene_assembled',scene=number,draft=draft,output=str(out),duration=duration)
|
||||
print(str(out));return out
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--scene',type=int,required=True);p.add_argument('--draft',action='store_true');a=p.parse_args();scene(a.scene,a.draft)
|
||||
Reference in New Issue
Block a user