JMP의 실행 프로그램과 HTTP 요청은 AI 도구와 FFmpeg를 사용하여 동영상을 만듭니다.
유튜브에서 완료된 프로젝트.
https://www.gutenberg.org/ebooks/67121 에서 책을 가져와서 텍스트를 디스크에 저장하세요. JSL을 작성하여 행당 한 단락씩 데이터 테이블에 텍스트를 넣습니다.
txt = Load Text File( "f:/PolarisAndTheGoddessGlorian.txt" );//
txt = Regex(
txt,
"\*\*\* START OF THE PROJECT GUTENBERG EBOOK .*? \*\*\*\s*(.*?)\*\*\* END OF THE PROJECT GUTENBERG EBOOK .*? \*\*\*",
"\1"
);
If( Length( txt ) != 438066,
Throw( "text?" )
);
// look for paragraphs separated by ~0D~0A~0D~0A
txt = Regex( txt, "\s*\r\n\r\n\s*", "❡", globalreplace );
txt = Regex( txt, "_", " ", globalreplace ); // italic indicated with _this_ style
txt = Regex( txt, "[\n\r]+", " ", globalreplace ); // all on one line
dtStory = New Table( "story", New Column( "paragraph", Character, "Nominal" ) );
rc = Pat Match(
txt,
Pat Repeat(
(Pat Pos( 0 ) | "❡") + //
(Pat Break( "❡" )) >> p + Pat Test(
dtStory << addrows( 1 );
dtStory:paragraph[N Rows( dtStory )] = p;
//Wait( 0.01 );
,
1
)
) + Pat R Pos( 0 )
);
dtStory << New Column( "OriginalSort", values( 1 :: N Rows( dtStory ) ) );
dtStory << New Column( "OriginalLength", formula( Length( paragraph ) ) );
dtStory << runformulas;
dtStory:OriginalLength << deleteformula;
두 명 이상의 독자 목소리를 실험하려면 몇 가지 경험적 방법을 만들어 텍스트 주위에 따옴표가 남성인지 여성인지 결정합니다. 이 단계를 건너뛰고 최고의 내레이터 음성을 사용하는 것도 좋은 선택일 수 있습니다. 목소리 전환이 좀 거슬립니다.
귀하의 브라우저는 오디오 요소를 지원하지 않습니다.
내레이터 음성
귀하의 브라우저는 오디오 요소를 지원하지 않습니다.
남성 연설자
귀하의 브라우저는 오디오 요소를 지원하지 않습니다.
여성 연사
// speaker identification...just enough to choose a narrator vs female vs male voice
TalkingVerbs = "said" | "asked" | "repeated" | "exclaimed" | "muttered" | "called" | "continued" | "queried" |
"cried" | "comment" | "thought" | "say" | "shouted" | "awestruck" | "questioned" | "turned" | "replied" |
"answered" | "rejoined " | "screamed" | "gasped" | "bowed" | "answered" | "smiled" | "laughed" | "answer" |
"spoke" | "interposed" | "sobbed" | "enjoined" | "warned" | "shook" | "lowered" | "grew" | "grumbled" | "smile" |
"whispered" | "put" | "interposed" | "hiccoughed" | "informed" | "added" | "frowned" | "nodded" | "questioned" |
"gasped" | "pointed" | "broke" | "inquired" | "spoke" | "looked" | "barked" | "interrupted" | "shouted" |
"addressed" | "continued" | "told" | "suggested" | "directed" | "cautioned" | "comment" | "concluded" | "pleaded"
| "spoke" | "grinned" | "drew" | "snarled" | "volunteered" | "counsel" | "persisted" | "spat" | "counseled" |
"lifted" | "shout" | "roared" | "prayed" | "howled" | "shrieked" | "told" | "mused" | "growled" | "shouting" |
"groaned" | "grunted" | "rambled" | "directed" | "went" | "breathed" | "called" | "commanded" | "shuddered" |
"began" | "rejoined";
MaleSpeakerNames = "he" | "everson" | "commander" | "polaris" | "coxswain" | "zenas" | "wright" | "scotchman" |
"his" | "mackechnie" | "geologist" | "lieutenant" | "scot" | "stranger" | "oleric" | "captain" | "brunar" |
"ensign" | "brooks" | "minos" | "mordo" | "king" | "bel-ar" | "raissa" | "priest" | "man" | "melton" | "janess" |
"men" | "jastla" | "scientist" | "wright" | "voice" | "zoar" | "soldier" | "son" | "broddok" | "albar" |
"engineer" | "zind" | "chief" | "vedor" | "master" | "they" | "heralds";
FemaleSpeakerNames = "she" | "her" | "girl" | "rose" | "emer" | "lady" | "memene" | "queen" | "goddess" |
"glorian";
gapchars = ":; ,.?!\!n\!r"; // gaps between words ... for example he reluctantly said
gap = Pat Span( gapchars ) | Pat Pos( 0 ) | Pat R Pos( 0 );
distanceBetween = 1;
femalePattern = (gap + FemaleSpeakerNames >> spkr + gap + Pat Repeat(
Pat Break( gapchars ) + GAP,
0,
Expr( distanceBetween ),
RELUCTANT
) + TalkingVerbs >> verb + gap) //
| (gap + TalkingVerbs >> verb + gap + Pat Repeat(
Pat Break( gapchars ) + GAP,
0,
Expr( distanceBetween ),
RELUCTANT
) + FemaleSpeakerNames >> spkr + gap);
malePattern = (gap + MaleSpeakerNames >> spkr + gap + Pat Repeat(
Pat Break( gapchars ) + GAP,
0,
Expr( distanceBetween ),
RELUCTANT
) + TalkingVerbs >> verb + gap) //
| (gap + TalkingVerbs >> verb + gap + Pat Repeat(
Pat Break( gapchars ) + GAP,
0,
Expr( distanceBetween ),
RELUCTANT
) + MaleSpeakerNames >> spkr + gap);
// add a column for the speaker: narrator, male, female. The words above and algorithm below are
// specific to this story (and the time it was written, 1917)
// a paragraph is either all narrator or narrator + male speaker or narrator + female speaker
// usually there is a talkingverb and male or female speaker name in the paragraph. if not,
// the previous paragraph might end with
// he said:
// etc in the same format. if not, and the paragraph does not have a trailing matching quotation mark,
// the male/female speaker will continue at the next open quotation mark. if not, there are two places where
// the speaker should alternate male/female.
// for most of the book, male speaker will be the correct guess.
dtstory << New Column( "speaker", Character, "Nominal" );
prevspeaker = "narrator";
currspeaker = "narrator";
quotationmark = "\!"";
missingEndQuote = 0;
quotation = quotationmark + (//
(Pat Break( quotationmark ) + quotationmark + Pat Test(
missingEndQuote = 0;
1;
)) //
| //
(Pat Rem() + Pat Test(
missingEndQuote = 1;
1;
)));
이러한 패턴인 malePattern 및 femalePattern은 다음과 같은 구조를 식별합니다.
"이제 이 모든 일들 속에서 마침내 내 길을 찾은 것 같아요." 그는 중얼거렸다.
또는
"정말 잘했어요, 착한 올레릭." 그녀가 재빨리 말했다. "나의 왕께서는 잃어버린 파데메를 용서해 주실 것입니다. 파데메를 잃은 것은 확실히 당신의 잘못이 아니었습니다. 그리고 이것들은 나에게 많은 파데메의 가치가 있습니다." 그녀는 크기가 적당하고 광택이 좋은 진주 두 개를 골라 올레릭에게 주었습니다.
그 와 그녀는 캐릭터의 다양한 이름과 별명이 될 수도 있습니다. 그리고 그 큰 동사 목록에서 뽑아낼 수 있다고 말했습니다 ... 저자는 그것들을 순환하면서 약간의 즐거움을 얻었을 것입니다.
다음으로, 화자를 교대하기 위한 일부 논리와 휴리스틱이 실패한 특수 사례(865)를 사용하여 행별로 패턴을 적용합니다. (장 번호는 IX와 같은 로마 숫자가 "9"를 나타내도록 하기 위해 약간 사용됩니다. 이는 훈련 데이터의 일부가 아닌 것 같습니다.)
iCHAPTER = 0;
chaps = {"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"};
For( irow = 1, irow <= N Rows( dtstory ), irow += 1,
p = dtstory:paragraph[irow];
continueSpeaker = missingEndQuote; // from previous paragraph
hasquote = Pat Match( p, quotation, "" );
While( Pat Match( p, quotation, "" ), 0 ); // remove all quoted bits before looking for "he said"
explicitSpeaker = 0;
For( distanceBetween = 0, distanceBetween < 5 & explicitSpeaker == 0, distanceBetween += 1,
If(
Pat Match( p, femalePattern ), // try giving female precedence around 1322
prevspeaker = currspeaker;
currspeaker = "female";
explicitSpeaker = 1;//
,
Pat Match( p, malePattern ),
prevspeaker = currspeaker;
currspeaker = "male";
explicitSpeaker = 1;//
)
);
If( irow == 865,
Show( irow, currspeaker, prevspeaker );
prevspeaker = "male";
);
If( hasquote,
If(
explicitSpeaker, dtstory:speaker[irow] = currspeaker || " explicit", /* else if*/continueSpeaker,
dtstory:speaker[irow] = currspeaker || " unquote", /*else if*/
Ends With( dtstory:paragraph[irow - 1], ":" ), dtstory:speaker[irow] = currspeaker || " leading", // else alternating speakers
{prevspeaker, currspeaker} = Eval List( {currspeaker, prevspeaker} );
dtstory:speaker[irow] = currspeaker || " alternate";
)
, //
dtstory:speaker[irow] = "narrator" || " " || currspeaker || Char( explicitSpeaker )
);
);
저자는 인용문에 대해 지금은 다를 수도 있는 몇 가지 엄격한 규칙을 사용했습니다. JSL은 강성을 활용합니다. 동일한 화자가 다음 단락에서 계속되는 경우 후미 닫는 따옴표는 항상 생략됩니다. 두 명의 스피커가 교대로 사용되는 경우 순서가 확정되면 해당 부분을 생략할 수 있습니다. 그리고 이전 단락의 끝이 다음 단락의 화자를 결정할 수 있습니다.
배고픈 남자들이 식사를 마치자 소녀가 다시 말했습니다.
"이봐, 바다에서 온 남자야, 아들라즈 마을의 감옥에 아름다운 여인이 당신을 기다리고 있다고 들었습니다. 그게 사실인가요?"
"예, 아가씨. 사실이에요." 폴라리스가 말했습니다. 그리고 그는 한숨을 쉬었다.
"그런데 당신은 그녀를 풀어주기 위해 훌륭한 주인을 그곳으로 인도하고 있습니까?" 그 소녀는 계속해서 말했다.
"예, 가능하다면요."
"하지만 아들라즈로 가려면 이 바르메 요새를 점령해야 하는데, 이 요새는 깨지기 힘든 곳입니다. 그렇지 않나요?"
"그건 사실이에요, 아가씨."
"글쎄요, 들어보세요." 소녀는 자리에서 일어나 테이블로 다가왔다. "진실한 남자가 거의 없을 만큼 여자에게 진실한 당신, 아마도 가난하고 멸시받고 버림받은 라울라가 이 일에 당신에게 어느 정도 도움이 될 것입니다."
오디오는 클릭 시 오디오를 재생하는 이벤트 핸들러가 있는 표현식 열에 저장됩니다. WavWriter 함수는 아래 어딘가에 있는 다른 파일의 JSL입니다.
dtStory << New Column( "audio",
expression,
Set Property(
"Event Handler",
Event Handler(
Click(JSL Quote(
Function( {thisTable, thisColumn, iRow},
RiffUtil:WavWriter( "$temp/yyy.wav", Blob To Matrix( Gzip Uncompress( eval(thisTable:thisColumn[iRow]) ), "int", 2, "big", 1 ), 22050 )
);
) )
)
)
);
dtStory << New Column( "nsamples" );
For( irow = 1, irow <= N Rows( dtstory ), irow += 1,
p = dtStory:paragraph[irow];
// do some fix-ups * * *, Chapter eye, --, "single"
pprev=p;
p = Regex( p, "\!"([a-zA-Z,]+)\!"", "\1", GLOBALREPLACE ); // he was "all" alone ==> he was all alone don't switch voice "Ivanhoe,"
If( p != pprev,
Show( "quoteword", irow, pprev, p )
);
pprev=p;
p = Regex( p, "\!"--", ",\!" ", GLOBALREPLACE ); // "go away"--he said--"or else" ==> "go away," he said, "or else" don't start segment with comma?
If( p != pprev,
Show( "quotehyphens", irow, pprev, p )
);
pprev=p;
p = Regex( p, "--", ", ", GLOBALREPLACE ); // -- does not slow down, but comma does
If( p != pprev,
Show( "hyphens", irow, pprev, p )
);
pprev=p;
p = Regex( p, "( [A-Z][a-z]?)\.", "\1", GLOBALREPLACE ); // _Mr. brown ==> _Mr brown
If( p != pprev,
Show( "Mr. B.", irow, pprev, p )
);
pprev=p;
If( Pat Match( p, Pat Pos( 0 ) + Pat Span( " *\n\r" ) + Pat R Pos( 0 ), "" ), // * * * ==> empty
Show( "asterix", irow, pprev, p )
);
pprev=p;
If( Pat Match( p, Pat Pos( 0 ) + "CHAPTER " ), // roman ==> one, two, ... ten
iCHAPTER += 1;
p = "Chapter " || chaps[iChapter] || ".";
Show( "chapno", irow, pprev, p );
);
if(trim(p)!="",samples = paragraphToSamples( p, dtStory:speaker[irow] ),samples=j(22050,1,0));//* * * paragraph
dtStory:audio[irow] = Gzip Compress( Matrix To Blob( samples, "int", 2, "big" ) );
dtStory:nsamples[irow] = N Rows( samples );
Wait( .1 );
);
위의 JSL 중 상당 부분은 텍스트에 대한 몇 가지 추가 수정 작업을 수행하고 있습니다. AI 내레이터는 -- 작동하지 않는 방식으로 훈련되었으므로 원하는 일시 중지를 얻기 위해 -- 를 로 대체합니다. 그런 다음 로 시작하는 일부 인용문을 수정하여 작동하게 해야 했습니다. 이 중 많은 부분이 책과 해설자에게만 해당됩니다. 마지막에 ParagraphToSamples는 22.050kHz 오디오를 만든 다음 압축하여 표현식 열의 blob에 저장합니다.
In addition to the paragraph text, there is an audio and pict column. The audio column has a click handler to play the binary data. This is the denouement of1729 paragraphs. 단락 텍스트 외에도 오디오 및 그림 열이 있습니다. 오디오 열에는 이진 데이터를 재생하는 클릭 핸들러가 있습니다. 이것은 1729 단락의 비난입니다. 그림을 만드는 코드를 살펴보기 전에 문단ToSamples로 들어가 첫 번째 새 HTTP 요청을 살펴보겠습니다. 첫 번째 함수인 getSamples는 TTS(텍스트 음성 변환) 서버에서 GET을 수행하는 새 HTTP 요청에 대한 논리를 보여줍니다. 두 번째 함수인paragraphToSamples는 단락을 음성용 세그먼트로 분할하고 각 세그먼트에 대해 getSamples를 사용하며 단락용 샘플을 함께 결합합니다. 새 HTTP 요청은 필드를 사용하여 화자 이름과 텍스트를 로컬 컴퓨터(아래 Python)에서 실행되는 서버에 대한 HTML GET 요청으로 패키징합니다.
include("f://redwindows/TTS_wav_support.jsl");
/* on dell 780 main,
python tts2.py
to start the server for text-to-speech
*/
getsamples = Function( {text, speaker, rate},
{fields, req, b, c},
fields = Associative Array();
fields["text"] = text;
fields["speaker_id"] = speaker;
fields["language_id"] = "en";
req = New HTTP Request( URL( "http://192.168.0.57:8080//api/tts" ), Method( "GET" ), Query String( fields ) );
b = req << send;
(Save Text File( "$temp/xxx.wav", b ));
c = (RiffUtil:loadWav( "$temp/xxx.wav" ));
If( speaker == "jenny" & rate != 48000,
c = RiffUtil:resample( c, 48000, rate )
);
If( speaker != "jenny" & rate != 22050,
c = RiffUtil:resample( c, 22050, rate )
);
c;
);
/****
speakers=
{"jenny", "ED\!n", "p225", "p226", "p227", "p228", "p229", "p230", "p231", "p232", "p233", "p234", "p236", "p237", "p238", "p239", "p240", "p241",
"p243", "p244", "p245", "p246", "p247", "p248", "p249", "p250", "p251", "p252", "p253", "p254", "p255", "p256", "p257", "p258", "p259", "p260",
"p261", "p262", "p263", "p264", "p265", "p266", "p267", "p268", "p269", "p270", "p271", "p272", "p273", "p274", "p275", "p276", "p277", "p278",
"p279", "p280", "p281", "p282", "p283", "p284", "p285", "p286", "p287", "p288", "p292", "p293", "p294", "p295", "p297", "p298", "p299", "p300",
"p301", "p302", "p303", "p304", "p305", "p306", "p307", "p308", "p310", "p311", "p312", "p313", "p314", "p316", "p317", "p318", "p323", "p326",
"p329", "p330", "p333", "p334", "p335", "p336", "p339", "p340", "p341", "p343", "p345", "p347", "p351", "p360", "p361", "p362", "p363", "p364",
"p374", "p376"};
bestMaleSpeakers={"p226","p251"/*best*/,"p254","p256"/*alternate*/,"p299","p302"/*alternate*/,"p330"};
bestFemaleSpeakers={"p225","p243","p250","p273"/*best*/,"p274"/*alternate*/,"p294","p300"/*alternate*/};
****/
finalSampleRate = 22050;
malevoice = "p256"/*male*/;
narratorvoice = "p273"/*narrator*/;
femalevoice = "p300"/*female*/;
quotationmark = "\!"";
paragraphToSamples = Function( {paragraph, speaker},
{all, part, parta, rc, currentSpeaker, quoteSpeaker},
quoteSpeaker = Match( Substr( speaker, 1, 3 ), "mal", malevoice, "fem", femalevoice, narratorvoice );
all = J( 0, 1, 0 );// start with empty matrix to join the voice segments together
currentSpeaker = narratorvoice;
// quotation marks toggle between narrator and speaker, always start with narrator
// before first quotation mark. process whole paragraph. possible missing close
// quotation mark at end.
While( Length( paragraph ) > 0,
// break at quotation mark and remove from paragraph. take remainder if no more quotation marks.
rc = Pat Match( paragraph, (Pat Break( quotationmark ) >> part + quotationmark) | Pat Rem() >> part, "" );
If( rc != 1,
Throw( "patmatch?" )
);
If( Length( part ), // leading quotation mark (len==0) will switch from narrator to speaker
parta = getsamples( part, currentSpeaker, finalSampleRate ); // convert text to audio
all = all |/ parta;
);
// alternate speaker/narrator. Typically (ralph said) "Let's go" said ralph, "they are waiting."
If( currentSpeaker == narratorvoice,
currentSpeaker = quoteSpeaker//
, //
currentSpeaker = narratorvoice
);
);
all // return the joined samples
);
로컬 서버의 Python 코드는 다음과 같습니다(위의 getSamples 작동 방식과 일치합니다).
# modified from https://github.com/coqui-ai/TTS/discussions/2740#discussion-5367521 , thanks @ https://github.com/masafumimori
import io
import json
import os
from typing import Union
from flask import Flask, request, Response, send_file
from TTS.utils.synthesizer import Synthesizer
from TTS.utils.manage import ModelManager
from threading import Lock
app = Flask(__name__)
manager = ModelManager()
#model_path = None
#config_path = None
speakers_file_path = None
vocoder_path = None
vocoder_config_path = None
#model_name = "tts_models/en/vctk/vits"
#model_name = "tts_models/en/ek1/tacotron2"
# model_name = "tts_models/en/jenny/jenny"
model_path1, config_path1, model_item1 = manager.download_model("tts_models/en/vctk/vits")
# load models
synthesizer1 = Synthesizer(
tts_checkpoint=model_path1,
tts_config_path=config_path1,
tts_speakers_file=speakers_file_path,
tts_languages_file=None,
vocoder_checkpoint=vocoder_path,
vocoder_config=vocoder_config_path,
encoder_checkpoint="",
encoder_config="",
use_cuda=False,
)
model_path2, config_path2, model_item2 = manager.download_model("tts_models/en/jenny/jenny")
# load models
synthesizer2 = Synthesizer(
tts_checkpoint=model_path2,
tts_config_path=config_path2,
tts_speakers_file=speakers_file_path,
tts_languages_file=None,
vocoder_checkpoint=vocoder_path,
vocoder_config=vocoder_config_path,
encoder_checkpoint="",
encoder_config="",
use_cuda=False,
)
print(
" > Available speaker ids: (Set --speaker_idx flag to one of these values to use the multi-speaker model."
)
print(synthesizer1.tts_model.speaker_manager.name_to_id)
lock = Lock()
@app.route("/api/tts", methods=["GET"])
def tts():
with lock:
text = request.args.get("text")
speaker_idx = request.args.get("speaker_id", "")
language_idx = request.args.get("language_id", "")
style_wav = request.args.get("style_wav", "")
style_wav = style_wav_uri_to_dict(style_wav)
print(f" > Model input: {text}")
print(f" > Speaker Idx: {speaker_idx}")
print(f" > Language Idx: {language_idx}")
if speaker_idx=="jenny":
wavs = synthesizer2.tts(text, language_name=language_idx, style_wav=style_wav)
out = io.BytesIO()
synthesizer2.save_wav(wavs, out)
else:
wavs = synthesizer1.tts(text, speaker_name=speaker_idx, language_name=language_idx, style_wav=style_wav)
out = io.BytesIO()
synthesizer1.save_wav(wavs, out)
return send_file(out, mimetype="audio/wav")
def style_wav_uri_to_dict(style_wav: str) -> Union[str, dict]:
if style_wav:
if os.path.isfile(style_wav) and style_wav.endswith(".wav"):
return style_wav # style_wav is a .wav file located on the server
style_wav = json.loads(style_wav)
return style_wav # style_wav is a gst dictionary with {token1_id : token1_weigth, ...}
return None
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Python 코드는 Linux 컴퓨터에서 실행됩니다. Windows에서는 TTS 지원을 사용할 수 없습니다. HTTP 요청은 상관하지 않습니다! 코드를 알려 주신 @masafumimori에게 감사드립니다. 플라스크를 다시 배우지 않아도 됩니다.
$ python tts2.py
> tts_models/en/vctk/vits is already downloaded.
> Using model: vits
> Setting up Audio Processor...
| > sample_rate:22050
...
> Setting up Audio Processor...
| > sample_rate:48000
...
| > win_length:2048
> Available speaker ids: (Set --speaker_idx flag to one of these values to use the multi-speaker model.
{'ED\n': 0, 'p225': 1, 'p226': 2, 'p227': 3, 'p228': 4, 'p229': 5, 'p230': 6, 'p231': 7,
...
106, 'p374': 107, 'p376': 108}
* Serving Flask app 'tts2'
* Debug mode: off
INFO:werkzeug:WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:8080
* Running on http://192.168.0.57:8080
INFO:werkzeug:Press CTRL+C to quit
Cuda 지원이 없는 TTS 서버는 위에서 선택한 음성으로 오래된 하드웨어에서 실행됩니다. 충분히 빠릅니다. 응답을 제공하기 위해 GET 요청을 기다리는 동안 창이 시간 초과되지 않았습니다. 일반적으로 오디오가 재생되는 데 걸리는 시간과 거의 같은 시간에 답변이 반환되지만 다른 음성의 경우 더 길어질 수 있습니다. 이것이 문제가 된다면 Python TTS 서버는 easy Diffusion 서버가 작동하는 방식과 유사하게 폴링 쿼리로 다시 작성할 수 있습니다(아래).
(많은 메모리와 Cuda 코어를 갖춘 Nvidia 그래픽 카드와 더 큰 전원 공급 장치를 갖춘 최신 컴퓨터가 있다면 아마도 10배에서 100배까지 성능이 향상될 것입니다.)
TTS 서버는 다음 패키지에 의존합니다:
pip install -U TTS
https://pypi.org/project/TTS/
https://docs.coqui.ai/en/latest/installation.html
영화
2024년 초 현재 텍스트에서 그림을 생성하는 여러 사이트를 찾을 수 있습니다. 무료 사이트는 사진을 찍는 데 1~5분 정도 느린 경향이 있습니다. 또한 많은 사이트에서 사용하고 있는 Stable Diffusion 용 소프트웨어와 훈련된 모델을 다운로드할 수도 있습니다. https://github.com/easydiffusion/easydiffusion은 win과 linux에서 작동합니다. 아래 코드는 cuda를 지원하지 않는 4개의 Linux 시스템 세트를 가정하고 2분마다 그림을 만듭니다(상황에 따라 더 빠르거나 느림...) stable 확산의 나머지 기반 웹 버전에는 여러 API가 있는 것 같습니다. 아래 코드는 쉬운 확산 버전에서 작동합니다.
이전 단락, 현재 단락, 다음 단락의 텍스트를 사용하여 각 단락마다 그림이 생성됩니다. 다른 키워드(컬러, 드라마틱, HD, 풍경, 현실감, 판타지, 시네마틱)와 일부 제외 키워드(nsfw, 만화, 포켓몬, 텍스트, 캡션, 단어, 문자, 앨범, 패널, 종교, 시, 산문)가 포함됩니다. . text , caption , word , pokemon , comic 등과 같은 부정적인 단어는 다음과 같은 이미지를 피하려는 시도였습니다.
Not sure how many points this card is worth.이 카드의 가치가 몇 점인지 잘 모르겠습니다.
I can't look at it without trying to read it.읽으려고 노력하지 않고는 볼 수가 없어요.
Word balloons!단어 풍선!
What are they saying?그들은 무슨 말을하는거야?
어쨌든 이들 중 일부는 비디오에 등장했습니다. 부정적인 단어는 AI 모델을 이미지에서 멀어지게 하기 위한 제안일 뿐입니다.
위에서 설명한 방식으로 오디오 TTS API는 HTTP GET을 사용하여 오디오 샘플(.wav 또는 .mp3 유사 파일)을 검색했습니다. GET이 답을 계산하고 몇 초 안에 반환할 수 있었기 때문에 제대로 작동했습니다. 1분 정도 시간이 걸렸다면 많은 도구(창, JMP, 브라우저)는 연결이 끊어졌다고 가정하고 결과 대신 오류 메시지를 생성했습니다. 아래의 Easy Diffusion API는 서버에서 더 긴 프로세스를 트리거합니다. 느린 시스템에서는 몇 분 정도 소요됩니다. 나는 API에 대한 충분한 설명을 찾지 못했고 브라우저와 서버의 상호 작용을 연구하기 위해 FireFox 또는 Chrome 브라우저의 F12 검사를 사용했습니다. JSL에서는 브라우저처럼 작동하는 JMP가 키워드와 매개변수가 포함된 요청을 POST한 다음 그림이 준비될 때까지 서버에서 상태를 가져오는 것을 볼 수 있습니다. 초기 POST에서 서버는 요청에 고유한 상태 URL로 응답합니다. JSL은 병렬로 실행되는 4개의 시스템에 대한 서버 및 상태 URL 목록을 유지함으로써 더욱 복잡해지며, 각각은 데이터 테이블의 다른 행에서 독립적으로 작동합니다.
addImage 루틴의 원래 디자인은 행의 현재 이미지 열이 모두 채워진 경우 테이블에 새 이미지 열을 추가합니다. 아이디어는 잠시 동안 실행되도록 두는 것이었습니다... 결국에는 약간 다른 매개 변수를 사용하여 서로 다른 테이블의 두 열에서 가져온 이미지를 사용했습니다.
addImage = Function( {img, irow, seed, request},
{c1, c2, icol},
For( icol = 2, icol < N Cols( dt ), icol += 2,
If( Is Empty( dt[irow, icol] ),
c1 = Column( dt, icol );
c2 = Column( dt, icol + 1 );
Break();
)
);
If( icol >= N Cols( dt ),
c1 = dt << New Column( "pict",
expression,
"None",
Set Property(
"Expression Role",
Expression Role(
"Picture",
MaxSize( 512, 512 ),
StretchToMaxSize( 0 ),
PreserveAspectRatio( 1 ),
Frame( 0 )
)
),
Set Display Width( 522 )
);
c2 = dt << New Column( "meta",
expression,
"None",
Set Property( "Expression Role", ),
Set Display Width( 20 )
);
);
c1[irow] = img;
c2[irow] = Eval Insert( "seed: ^seed^\!nrequest: ^request^" );
);
여러 서버는 단일 LocalHost로 시작되었습니다. 서버 테이블에는 상태 URL과 행 번호도 보관되어 있으므로 상태 URL이 최종적으로 그림을 제공할 때 올바른 행에 저장할 수 있습니다.
// these servers need to run the easydiffusion server...more would be better, cuda, etc
// with 4 I get a picture a minute; 1800/60 -> 30 hours to fill in the table.
// my old hardware does not support cuda cards good enough to use them. bummer.
LHtable = [// originally "localhost", now there are N hosts
1 => ["host" => "192.168.0.38:9000", // thinkd.local
"statusURL" => 0, "targetRow" => 0], 2 => ["host" => "192.168.0.37:9000",
// thinkc.local
"statusURL" => 0,
"targetRow" => 0], 3 => ["host" => "192.168.0.36:9000",
// thinkb.local
"statusURL" => 0,
"targetRow" => 0], 4 => ["host" => "192.168.0.35:9000",
// dell9010.local
"statusURL" => 0,
"targetRow" => 0]];
getLH = Function( {},
{i},
For( i = 1, i <= N Items( LHtable ), i += 1,
If( LHtable[i]["targetRow"] == 0,
Return( i )
)
);
Return( 0 );
);
세 개의 단락을 결합하고 일부 원하지 않는 문자를 제거하는 별도의 단계입니다. 프롬프트가 왜 표현식인지 잘 모르겠습니다. 문자여야 하지만 어쨌든 작동합니다. 이 버전은 적당한 길이의 단락에 대한 테스트를 피하기 위해 if(1을 사용합니다.
안정 확산 모델에는 한 사진에서 다음 사진까지의 메모리가 없습니다. 세 개의 문단은 장기 기억을 시뮬레이션하려는 잘못된 시도입니다. 사진 속 캐릭터는 머리색, 의상 등을 이미지별로 바꿔드립니다. 이에 대한 더 나은 모델이 곧 있을 수 있습니다.
// pre-pass the story into the table.
dt = New Table( "Untitled", New Column( "prompt", expression ), Set Cell Height( 522 ) );
For( iom = 1, iom <= N Rows( dtStory ), iom += 1,
txtprev = dtStory:paragraph[Max( 1, iom - 1 )];
txtprev = Regex( txtprev, "[^a-zA-Z,'.?]+", " ", GLOBALREPLACE );
txt = dtStory:paragraph[iom];
txt = Regex( txt, "[^a-zA-Z,'.?]+", " ", GLOBALREPLACE );
txtnext = dtStory:paragraph[Min( N Rows( dtStory ), iom + 1 )];
txtnext = Regex( txtnext, "[^a-zA-Z,'.?]+", " ", GLOBALREPLACE );
txt = " ( " || txt || " ) " || txtprev || " " || txtnext;
If( 1 /*300 < Length( txt ) < 700*/, // choose modest length paragraphs
dt << Add Rows( 1 );
//iom += 20;
dt:prompt[N Rows( dt )] = txt;
);
);
이 마지막 모놀리식 블록은 모든 무거운 작업을 수행합니다. 위의 LH 구조를 사용하여 여러 서버를 바쁘게 유지합니다. 또한 원래는 모델을 실험하기 위해 작성되었습니다. 이제 모델 목록은 sd-v1-5입니다. 마찬가지로 추론 단계의 수는 30개에 불과합니다. 그리고 외부 루프는 행을 통과합니다.
더보기...프레임 1-30은 진화하는 추론 단계입니다.
따라서 7번째 줄로 이동합니다. 유휴 서버가 없는 동안 각 서버에 상태 확인을 위해 GET을 보내고, 준비가 되면 이미지를 저장하고 서버를 유휴 상태로 표시합니다. 그런 다음 훨씬 더 아래로 유휴 서버가 있는 경우 다음 이미지 생성을 시작하라는 요청을 POST합니다. HTTP POST 요청에서 매개변수 이름을 앞에 X로 바꾸면 매개변수가 어떻게 숨겨졌는지 확인할 수 있습니다. 값이 필요한 경우 서버는 요청에서 해당 값을 볼 수 없으며 대부분 기본값을 사용할 것입니다. 내 경우에는 얼굴 교정이 도움이 되지 않는다고 판단했는데...나쁜 결정이었을 수도 있습니다.
While( 1,
For( irow = 1, irow <= N Rows( dt ), irow += 1,
For Each( {modelname},
{/*"wd-v1-3-full", "animagine-xl-3.0", "dreamlike-photoreal-2.0", */"sd-v1-5"/*, "sd21-unclip-h", "sd21-unclip-l"*/
},
For Each( {infsteps}, {30},
While( (iLH = getLH()) == 0,
For( iLH = 1, iLH <= N Items( LHtable ), iLH += 1,
request = New HTTP Request(
url( LHtable[iLH]["statusURL"] ),
Method( "GET" ),
Headers( {"Accept: application/json"}, {"Content-Type: application/json"} )
);
StatusHandle = request << Send;
If( !Is Empty( StatusHandle ),
statusReport = Parse JSON( StatusHandle );
If(
Is Associative Array( statusreport ) & Contains( statusreport, "status" ) &
statusreport["status"] == "succeeded",
// unpack final image data(s)
If( N Items( statusReport["output"] ) != 1,
Throw( "unsupported multiple items" )
);
For( i = 1, i <= N Items( statusReport["output"] ), i++,
addImage(
Open(
Decode64 Blob(
Regex(
statusReport["output"][i]["data"],
"data:image/jpeg;base64,(.*)",
"\1"
)
),
"jpg"
),
LHtable[iLH]["targetRow"],
Char( statusReport["output"][i]["seed"] ),
Char( statusReport["render_request"] )
)
);
Beep();
LHtable[iLH]["targetRow"] = 0;
LHtable[iLH]["statusURL"] = 0;//
, /*else if*/Is Associative Array( statusreport ) & Contains( statusreport, "step" ) &
Contains( statusreport, "output" ),
Throw( "needs work" );
); //
, //else no status update...
Wait( 1 )
);
)
);
If( iLH > 0, // found idle host...
txt = Regex( dt:prompt[irow], "jean", "john", globalreplace, ignorecase ); // try name tweak
txt = Regex( txt, "angelica", "susan", globalreplace, ignorecase ); // ditto
request = New HTTP Request(
url( LHtable[iLH]["host"] || "/render" ),
Method( "POST" ),
Headers( {"Accept: application/json"}, {"Content-Type: application/json"} ),
// Cinematic, Dynamic Lighting, Serene, by Salvador Dalí, Detailed and Intricate, Realistic, Photo, Dramatic, Polaroid, Pastel, Cold Color Palette, Warm Color Palette
JSON(
Eval Insert(
"\[
{"prompt":" color, Dramatic, HD, Landscape, Realistic, Fantasy, Cinematic, ^txt^",
"seed":^char(randominteger(1,4294967295))^,
"negative_prompt":"nsfw, comic, pokemon, text, caption, words, letters, album, panel, religion, poem, prose",
"num_outputs":1,
"num_inference_steps":^char(infsteps)^,
"guidance_scale":7.5,
"width":512,"height":512,
"XXXXuse_face_correction":"GFPGANv1.4",
"use_upscale":"RealESRGAN_x4plus",
"upscale_amount":"4",
"vram_usage_level":"balanced",
"sampler_name":"euler_a",
"use_stable_diffusion_model":"^modelname^",
"clip_skip":false,
"use_vae_model":"",
"stream_progress_updates":true,
"stream_image_progress_interval":2,
"stream_image_progress":false,
"show_only_filtered_image":true,
"enable_vae_tiling":true
}]\"
)
)
);
// occasionally the first send seems to happen when the server is ... asleep? The 2nd always works?
For( itry = 1, itry <= 10, itry += 1,
GenHandle = request << Send;
If( Is Empty( GenHandle ),
If( itry == 10,
Throw( "failed to connect, stable diffusion not running?" )//
, // else
Write( "\!nwaiting" );
Wait( 1 );
)
, //else
Break()
);
);
// above requested a picture. it is not ready yet, but it gave a URL to ask if ready. Below, ask status once/second.
LHtable[iLH]["statusURL"] = LHtable[iLH]["host"] || Parse JSON( GenHandle )["stream"];
LHtable[iLH]["targetRow"] = irow; //
);
)
)
)
);
모든 작업이 완료되면 여러 개의 이미지 데이터 테이블이 단일 테이블로 결합될 수 있습니다. 나는 약간의 JSL을 사용하여 1729쌍의 이미지를 클릭하여 불쾌한 것을 제거하거나 가장 좋은 이미지를 선택했습니다.
// interactive picture selection
dt = open("f://redwindows/story.jmp");
dta = open("f://redwindows/pic1024A.jmp");
dtb = open("f://redwindows/pic1024B.jmp");
if(nrows(dt) !=1729 |nrows(dtA) !=1729 |nrows(dtB) !=1729, throw("rows") );
irow=1;
newwindow("chooser",
hlistbox(
vlistbox(abox=mousebox(textbox())),
vlistbox(spacerbox(size(300,1)),
hcenterbox(buttons=vlistbox(
hcenterbox(buttonbox("up",irow=max(1,irow-1);updateRow())),
hcenterbox(hlistbox(
vcenterbox(buttonbox("==>",dt:pict[irow]=dtA:pict[irow];updateRow())),
cbox=mousebox(textbox()),
vcenterbox(buttonbox("<==",dt:pict[irow]=dtB:pict[irow];updateRow()))
)),
hcenterbox(buttonbox("down",irow=min(nrows(dt),irow+1);updateRow()))
)),
hcenterbox(rbox = textbox()),
tbox=textbox()
),
vlistbox(bbox=mousebox(textbox()))
)
);
abox<<SETCLICKENABLE></SETCLICKENABLE>
<P><SPAN class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="The middle image is the one I chose for this paragraph, the least bad." style="width: 999px;"><span class="lia-inline-image-display-wrapper" image-alt="The middle image is the one I chose for this paragraph, the least bad." style="width: 999px;"><img src="https://us.v-cdn.net/6038925/uploads/images/61750i8B95C86D958F0606/chooser.png" role="button" title="chooser.png" alt="The middle image is the one I chose for this paragraph, the least bad." /><span class="lia-inline-image-caption" onclick="event.preventDefault();">The middle image is the one I chose for this paragraph, the least bad.</span></span><SPAN class="lia-inline-image-caption" onclick="event.preventDefault();">가운데 이미지는 제가 이 단락에서 선택한 이미지로 가장 덜 나쁩니다.</SPAN></SPAN></P>
<P>사진과 오디오, 문단이 모두 동일한 테이블에 있으면 RunProgram으로 FFmpeg를 구동하여 비디오로 결합할 수 있습니다. FFmpeg는 RunProgram의 쓰기 메커니즘에서 제공하는 stdin의 이미지 데이터를 쉽게 받아들일 수 있습니다. 따라서 완전한 오디오 파일을 만든 다음 이미지를 한 번에 하나씩 입력하세요. 저는 사진 사이에 맞춤 전환이 가능한 30FPS 비디오를 만들기로 결정했습니다. 4K 비압축 사진의 대역폭은...많습니다. 그래서 동일한 이미지를 초당 30번 다시 보내는 것을 방지하기 위해 Linux 파이프를 Python 프로그램으로 약간 추가했습니다. 그리고 마지막에는 각 장/비디오 시작 부분에 추가 슬라이드를 삽입하기로 결정했습니다.</P>
<CODE class=" language-jsl">chapter0based = 9; // 0..10, 0 is introduction
Include( "f://redwindows/TTS_wav_support.jsl" );
frame = J( 1080 * 2, 1920 * 2, 0 );
dtstory = Open( "/F:/redWindows/story.jmp", invisible );
dtpict = dtstory;//Open( "/F:/redWindows/pic1024B.jmp", invisible );
nn = 0;
imgrows = (N Rows( frame ) - 2048) / 2;
imgrows = imgrows :: (imgrows + 2047);
imgcols = imgrows;
textTop = 200;
textLeft = imgcols[N Cols( imgcols )] + 100;
textRight = N Cols( frame ) - imgcols[1];
textWide = textRight - textLeft;
dtstory << New Column( "position", formula( Col Cumulative Sum( :nsamples ) ) );
dtstory << runformulas;
//
chapters = 1 |/ (dtstory << getrowswhere( Starts With( paragraph, "CHAPTER " ) )) |/ (1 + N Rows( dtstory ));
audioSampleLength = Sum( dtstory:nsamples[chapters[chapter0based + 1] :: chapters[chapter0based + 1 + 1]] );
nParagraphRows = chapters[chapter0based + 1 + 1] - chapters[chapter0based + 1];
//dtStory = open("y://story.jmp");
sps = 22050;
//dtStory = Open( "f://redwindows/story.jmp" );
allsamples = J( sps + sps + audioSampleLength + sps, 1, 0 );// sps: title slide, sps:lead gray, ... , sps: trail gray
nSamps = sps + sps; // 1 sec silence before and after. extra second before for title
// 22050/30==735 audio samples/frame
// pre-calculate the durations for each frame. there is an extra black frame
// for the second of lead in audio, and an extra at the end.
frameCounts = J( 2 + nParagraphRows, 1, 0 );
pictureRow = J( 2 + nParagraphRows, 1, 0 );
frameCounts[1] = 30;
frameCounts[2 + nParagraphRows] = 30;
iFrameCount = 2;
iRunningSamples = 0;
For( irow = chapters[chapter0based + 1], irow < chapters[chapter0based + 1 + 1], irow += 1,
allsamples[nSamps + 1 :: nSamps + dtStory:nsamples[irow]] = Blob To Matrix( Gzip Uncompress( Eval( dtStory:audio[irow] ) ), "int", 2, "big", 1 );
nSamps += dtStory:nsamples[irow];
If( Mod( irow, 10 ) == 0,
Show( irow );
Wait( 0 );
);
iRunningSamples += dtStory:nsamples[irow];
nframes = Floor( iRunningSamples / 735 );
iRunningSamples -= nframes * 735;
pictureRow[iFrameCount] = irow;
frameCounts[iFrameCount++] = nframes;
);
Show( frameCounts, pictureRow );
f = RiffUtil:WavWriterHeader(
Eval Insert( "Y:\farm\PolarisAudio_Chapter_^chapter0based+1-1^.wav" ),
N Rows( allsamples ),
N Cols( allsamples ),
sps
);
File Size( f );
Save Text File( f, Matrix To Blob( allsamples, "int", 2, "little" ), Mode( "append" ) );
File Size( f );
curFrame = 1;
maxFrame = N Rows( frameCounts );
/* repeater.py - the frames are 33MB each, and repeated many times. This is a pipe program to repeat at the far end without retransmitting.
#!/usr/bin/python3
import sys
source = sys.stdin.buffer
dest = sys.stdout.buffer
while True:
binary = source.read(4)
if not binary:
break #eof
reps = int(binary.decode("utf-8"))
block = source.read(2160*3840*4)
for i in range(reps):
dest.write(block)
*/
tran = New Namespace(
"tran"
);
tran.setup = Function( {ir1, ir2},
{p1, p2, red1, green1, blue1, red2, green2, blue2, gray1, gray2, rank1, rank2},
If( ir1,
p1 = Eval( dtpict:pict[ir1] );
tran.dest = p1 << getpixels;//
, //
tran.dest = J( 2048, 2048, RGB Color( "gray" ) );
p1 = New Image( tran.dest );
);
If( ir2,
p2 = Eval( dtpict:pict[ir2] );//
, //
p2 = New Image( J( 2048, 2048, RGB Color( "gray" ) ) )
);
{tran.nc, tran.nr} = p1 << getsize;
tran.firsthalf = (1 :: 4011707)`;
tran.secondhalf = (4011708 :: (tran.nr * tran.nc))`;
{red1, green1, blue1} = p1 << getpixels( "rgb" );
gray1 = Shape( red1 + green1 + blue1, tran.nr * tran.nc, 1 );
{red2, green2, blue2} = p2 << getpixels( "rgb" );
gray2 = Shape( red2 + green2 + blue2, tran.nr * tran.nc, 1 );
rank1 = Rank( gray1 );
tran.y1 = Floor( (rank1 - 1) / tran.nc ) + 1;
tran.x1 = Mod( (rank1 - 1), tran.nc ) + 1;
tran.r1 = red1[rank1];
tran.g1 = green1[rank1];
tran.b1 = blue1[rank1];
rank2 = Rank( gray2 );
tran.y2 = Floor( (rank2 - 1) / tran.nc ) + 1;
tran.x2 = Mod( (rank2 - 1), tran.nc ) + 1;
tran.r2 = red2[rank2];
tran.g2 = green2[rank2];
tran.b2 = blue2[rank2];
);
tran.next = Function( {itrans},
{alpha, beta, x3, y3, xy, r3, g3, b3, firstz, secondz},
nframes = 30;
// For( itrans = 0, itrans < nframes, itrans += 1,
fract = (itrans + 1) / (nframes); // 0 is already transitioning, 29 is complete
alpha = (Cos( Pi() * fract ) + 1) / 2;// ease in, ease out
beta = 1 - alpha;
x3 = Round( alpha * tran.x1 + beta * tran.x2 );
y3 = Round( alpha * tran.y1 + beta * tran.y2 );
xy = (y3 - 1) * tran.nc + x3;
r3 = alpha * tran.r1 + beta * tran.r2;
g3 = alpha * tran.g1 + beta * tran.g2;
b3 = alpha * tran.b1 + beta * tran.b2;
// workaround problem at 4011707 (works) and 4011708 (fails)
firstz = RGB Color( r3[tran.firsthalf], g3[tran.firsthalf], b3[tran.firsthalf] );
secondz = RGB Color( r3[tran.secondhalf], g3[tran.secondhalf], b3[tran.secondhalf] );
tran.dest[xy[tran.firsthalf]] = firstz;
tran.dest[xy[tran.secondhalf]] = secondz;
//x=[1 2,3 4];x[[2,3,2,3]]=[55,66,77,88];x
//New Window( "", New Image( Shape( dest, nr, nc ) ) );
// should not need shape? Shape( tran.dest, tran.nr, tran.nc );
// rp << Write( Right( Char( 1e6 + 1 ), 4 ) ); //repeater.py expects 4-char repeat count, just 1 of each transition frame
// Wait( 0.1 );
// );
);
New Window( "", bb = Border Box( Text Box( "" ) ) );
rp = Run Program(
executable( "plink" ),
options(
{"dell9010",
// idea from https://stackoverflow.com/questions/34167691/pipe-opencv-images-to-ffmpeg-using-python
"\!"/mnt/nfsc/farm/repeater.py 2>repeater.stderr.txt|ffmpeg", "-y", // overwrite output
"-f", "rawvideo", // tell ffmpeg the stdin data coming in is
"-vcodec", "rawvideo", // "raw" pixels, uncompressed, no meta data
"-s", Eval Insert( "^ncols(frame)^X^nrows(frame)^" ), // raw video needs this meta data to know the size of the image
"-pix_fmt", "argb", // the JSL color values are easily converted to argb with MatrixToBlob
"-r", "30", // input FPS
"-thread_queue_size", "256", // 128 was slow. applies to the following input. is this in frames or some other unit? 8 produces a warning, 1024 is too big?
"-i", "-", // this is the pipe input. everything before this is describing the input data
//"-an", // disable audio, or supply a sound file as another input
"-i", Eval Insert( "/mnt/nfsc/farm/PolarisAudio_Chapter_^chapter0based+1-1^.wav" ), //
"-vcodec", "mpeg4", // encoding for the output
"-b:v", "5000k", // bits per second for the output
Eval Insert( "/mnt/nfsc/farm/polaris_Chapter_^chapter0based+1-1^.mp4" ), // the name of the output
Eval Insert(">ffmpeg.stdout^chapter0based+1-1^.txt 2>ffmpeg.stderr^chapter0based+1-1^.txt\!"")}
),
WriteFunction( // the WriteFunction is called when FFMPEG wants JMP to write some data to FFMPEG's stdin
Function( {this},
//Show( curframe, maxFrame, chapter0based + 1, chapters[chapter0based + 1] );
//Show( rp << canread, rp << isreadeof, rp << canwrite );
//Wait( 0.1 );
write(curframe,"/", maxFrame," ",hptime()/1e6," ");
Try(
If( curFrame < maxFrame,
If( curFrame == 1, // inject title slide
t = Text Box(
"\[
Built with JMP software, FFmpeg, Stable Diffusion
and TTS voices p273 (narrator), p256 (male), and p300 (female)
https://github.com/easydiffusion/easydiffusion
https://pypi.org/project/TTS/
https://github.com/coqui-ai/TTS
Polaris and the Goddess Glorian by Charles B. Stilson
available at https://www.gutenberg.org/ebooks/67121
Compilation Copyright © 2024 Craige Hales
]\",
<<SETWRAP></SETWRAP>
<P>첨부파일은 위에서 대부분 설명되어 있습니다. 그들은 약간의 생각 없이 이 프로젝트를 재현하기 위해 노력하지 않을 것입니다. 제가 뭔가를 빠뜨린 경우에는 댓글을 달아주세요.</P>
<P><SPAN class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="Is that...surprise? fear? (unused early image.)" style="width: 512px;"><span class="lia-inline-image-display-wrapper" image-alt="Is that...surprise? fear? (unused early image.)" style="width: 512px;"><img src="https://us.v-cdn.net/6038925/uploads/images/61751i3EC31DE70AD150AF/BadDog.jpg" role="button" title="BadDog.jpg" alt="Is that...surprise? fear? (unused early image.)" /><span class="lia-inline-image-caption" onclick="event.preventDefault();">Is that...surprise? fear? (unused early image.)</span></span><SPAN class="lia-inline-image-caption" onclick="event.preventDefault();">그게...놀랐나요? 두려움? (사용하지 않은 초기 이미지입니다.)</SPAN></SPAN></P>